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

6455 lines
234 KiB
Python

import json
import urllib.parse
import uuid
from dataclasses import asdict
from datetime import datetime, timezone
from unittest import mock
import pytest
from flask import Response
from opentelemetry.sdk.trace import ReadableSpan as OTelReadableSpan
import mlflow
from mlflow.entities import (
Experiment,
GatewayBudgetPolicy,
Issue,
IssueSeverity,
IssueStatus,
RunStatus,
ScorerVersion,
Span,
Trace,
TraceData,
TraceInfo,
TraceState,
ViewType,
)
from mlflow.entities._job import Job as JobEntity
from mlflow.entities._job_status import JobStatus
from mlflow.entities.gateway_budget_policy import (
BudgetAction,
BudgetDuration,
BudgetDurationUnit,
BudgetTargetScope,
BudgetUnit,
)
from mlflow.entities.model_registry import (
ModelVersion,
ModelVersionTag,
PromptVersion,
RegisteredModel,
RegisteredModelTag,
)
from mlflow.entities.model_registry.prompt_version import IS_PROMPT_TAG_KEY, PROMPT_TEXT_TAG_KEY
from mlflow.entities.presigned_download import PresignedDownloadUrlResponse
from mlflow.entities.presigned_upload import CreatePresignedUploadResponse
from mlflow.entities.trace_location import TraceLocation as EntityTraceLocation
from mlflow.entities.trace_metrics import (
AggregationType,
MetricAggregation,
MetricDataPoint,
MetricViewType,
)
from mlflow.environment_variables import MLFLOW_ENABLE_WORKSPACES
from mlflow.exceptions import (
MlflowException,
MlflowNotImplementedException,
MlflowTracingException,
)
from mlflow.gateway.budget_tracker.in_memory import InMemoryBudgetTracker
from mlflow.genai.review_queues import ReviewQueueType
from mlflow.genai.review_queues.review_queues import (
ReviewItemType,
ReviewQueue,
ReviewQueueItem,
ReviewStatus,
)
from mlflow.genai.scorers.online.entities import OnlineScoringConfig
from mlflow.protos.databricks_pb2 import (
INTERNAL_ERROR,
INVALID_PARAMETER_VALUE,
NOT_IMPLEMENTED,
RESOURCE_DOES_NOT_EXIST,
ErrorCode,
)
from mlflow.protos.issues_pb2 import (
CreateIssue,
SearchIssues,
UpdateIssue,
)
from mlflow.protos.model_registry_pb2 import (
CreateModelVersion,
CreateRegisteredModel,
DeleteModelVersion,
DeleteModelVersionTag,
DeleteRegisteredModel,
DeleteRegisteredModelAlias,
DeleteRegisteredModelTag,
GetLatestVersions,
GetModelVersion,
GetModelVersionByAlias,
GetModelVersionDownloadUri,
GetRegisteredModel,
RenameRegisteredModel,
SearchModelVersions,
SearchRegisteredModels,
SetModelVersionTag,
SetRegisteredModelAlias,
SetRegisteredModelTag,
TransitionModelVersionStage,
UpdateModelVersion,
UpdateRegisteredModel,
)
from mlflow.protos.prompt_optimization_pb2 import (
OPTIMIZER_TYPE_GEPA,
OPTIMIZER_TYPE_METAPROMPT,
OPTIMIZER_TYPE_UNSPECIFIED,
)
from mlflow.protos.review_queues_pb2 import (
CreateReviewQueue,
GetOrCreateUserQueue,
SetReviewQueueItemStatus,
UpdateReviewQueue,
)
from mlflow.protos.service_pb2 import (
BatchGetTraceInfos,
BatchGetTraces,
CalculateTraceFilterCorrelation,
CreateExperiment,
DeleteScorer,
DeleteTraceTag,
DeleteTraceTagV3,
GatewayEndpoint,
GetGatewayEndpoint,
GetScorer,
GetTrace,
LinkPromptsToTrace,
ListScorers,
ListScorerVersions,
QueryTraceMetrics,
RegisterScorer,
SearchExperiments,
SearchLoggedModels,
SearchRuns,
SearchTraces,
SearchTracesV3,
SetTraceTag,
SetTraceTagV3,
TraceLocation,
)
from mlflow.protos.webhooks_pb2 import ListWebhooks
from mlflow.server import (
ARTIFACTS_DESTINATION_ENV_VAR,
BACKEND_STORE_URI_ENV_VAR,
SERVE_ARTIFACTS_ENV_VAR,
app,
)
from mlflow.server.handlers import (
ARTIFACT_STREAM_CHUNK_SIZE,
STATIC_PREFIX_ENV_VAR,
ModelRegistryStoreRegistryWrapper,
TrackingStoreRegistryWrapper,
_batch_get_trace_infos,
_batch_get_traces,
_calculate_trace_filter_correlation,
_cancel_prompt_optimization_job,
_convert_path_parameter_to_flask_format,
_create_artifact_file_response,
_create_dataset_handler,
_create_experiment,
_create_issue,
_create_model_version,
_create_presigned_upload_url,
_create_prompt_optimization_job,
_create_registered_model,
_create_review_queue,
_delete_artifact_mlflow_artifacts,
_delete_dataset_handler,
_delete_dataset_tag_handler,
_delete_model_version,
_delete_model_version_tag,
_delete_registered_model,
_delete_registered_model_alias,
_delete_registered_model_tag,
_delete_scorer,
_delete_trace_tag,
_delete_trace_tag_v3,
_deprecated_search_traces_v2,
_download_artifact,
_get_ajax_path,
_get_dataset_experiment_ids_handler,
_get_dataset_handler,
_get_dataset_records_handler,
_get_gateway_endpoint,
_get_issue,
_get_latest_versions,
_get_model_version,
_get_model_version_by_alias,
_get_model_version_download_uri,
_get_or_create_user_queue,
_get_presigned_download_url,
_get_registered_model,
_get_request_message,
_get_rest_path,
_get_scorer,
_get_trace,
_get_trace_artifact_repo,
_get_workspace_scoped_repo_path_if_enabled,
_link_prompts_to_trace,
_list_artifacts_for_proxied_run_artifact_root,
_list_scorer_versions,
_list_scorers,
_list_webhooks,
_log_batch,
_query_trace_metrics,
_register_scorer,
_rename_registered_model,
_response_with_file_attachment_headers,
_search_evaluation_datasets_handler,
_search_experiments,
_search_issues,
_search_logged_models,
_search_model_versions,
_search_registered_models,
_search_runs,
_search_traces_v3,
_send_artifact,
_set_dataset_tags_handler,
_set_model_version_tag,
_set_registered_model_alias,
_set_registered_model_tag,
_set_review_queue_item_status,
_set_trace_tag,
_set_trace_tag_v3,
_transition_stage,
_update_issue,
_update_model_version,
_update_registered_model,
_update_review_queue,
_upload_artifact,
_upsert_dataset_records_handler,
_validate_source_run,
catch_mlflow_exception,
get_artifact_handler,
get_endpoints,
get_logged_model_artifact_handler,
get_model_version_artifact_handler,
get_trace_artifact_handler,
get_ui_telemetry_handler,
post_ui_telemetry_handler,
upload_artifact_handler,
)
from mlflow.store._unity_catalog.registry.rest_store import UcModelRegistryStore
from mlflow.store.artifact.artifact_repo import ArtifactRepository
from mlflow.store.artifact.azure_blob_artifact_repo import AzureBlobArtifactRepository
from mlflow.store.artifact.local_artifact_repo import LocalArtifactRepository
from mlflow.store.artifact.s3_artifact_repo import S3ArtifactRepository
from mlflow.store.entities.paged_list import PagedList
from mlflow.store.model_registry import (
SEARCH_MODEL_VERSION_MAX_RESULTS_THRESHOLD,
SEARCH_REGISTERED_MODEL_MAX_RESULTS_DEFAULT,
)
from mlflow.store.model_registry.rest_store import RestStore as ModelRegistryRestStore
from mlflow.store.tracking import MAX_RESULTS_QUERY_TRACE_METRICS
from mlflow.store.tracking.databricks_rest_store import DatabricksTracingRestStore
from mlflow.telemetry.schemas import Record, Status
from mlflow.tracing.analysis import TraceFilterCorrelationResult
from mlflow.tracing.constant import SpansLocation, TraceTagKey
from mlflow.tracing.utils import build_otel_context
from mlflow.utils.mlflow_tags import MLFLOW_ARTIFACT_LOCATION
from mlflow.utils.proto_json_utils import message_to_json
from mlflow.utils.validation import MAX_BATCH_LOG_REQUEST_SIZE
from mlflow.utils.workspace_context import WorkspaceContext
from mlflow.utils.workspace_utils import DEFAULT_WORKSPACE_NAME
@pytest.fixture
def mock_get_request_message():
with mock.patch("mlflow.server.handlers._get_request_message") as m:
yield m
@pytest.fixture
def mock_get_request_json():
with mock.patch("mlflow.server.handlers._get_request_json") as m:
yield m
@pytest.fixture
def mock_tracking_store():
with mock.patch("mlflow.server.handlers._get_tracking_store") as m:
mock_store = mock.MagicMock()
m.return_value = mock_store
yield mock_store
@pytest.fixture
def mock_model_registry_store():
with mock.patch("mlflow.server.handlers._get_model_registry_store") as m:
mock_store = mock.MagicMock()
mock_store.list_webhooks_by_event.return_value = PagedList([], None)
m.return_value = mock_store
yield mock_store
@pytest.fixture
def enable_serve_artifacts(monkeypatch):
monkeypatch.setenv(SERVE_ARTIFACTS_ENV_VAR, "true")
@pytest.fixture
def mock_evaluation_dataset():
from mlflow.protos.datasets_pb2 import Dataset as ProtoDataset
dataset = mock.MagicMock()
dataset.dataset_id = "d-1234567890abcdef1234567890abcdef"
dataset.name = "test_dataset"
dataset.digest = "abc123"
dataset.created_time = 1234567890
dataset.last_update_time = 1234567890
dataset.created_by = "test_user"
dataset.last_updated_by = "test_user"
dataset.tags = {"env": "test", "version": "1.0"}
dataset.experiment_ids = ["0", "1"]
dataset._records = []
dataset.schema = json.dumps({
"inputs": {"question": "string"},
"expectations": {"accuracy": "float"},
})
dataset.profile = json.dumps({"record_count": 0})
proto_dataset = ProtoDataset()
proto_dataset.dataset_id = dataset.dataset_id
proto_dataset.name = dataset.name
proto_dataset.digest = dataset.digest
proto_dataset.created_time = dataset.created_time
proto_dataset.last_update_time = dataset.last_update_time
proto_dataset.created_by = dataset.created_by
proto_dataset.last_updated_by = dataset.last_updated_by
proto_dataset.schema = dataset.schema
proto_dataset.profile = dataset.profile
dataset.to_proto = mock.MagicMock(return_value=proto_dataset)
return dataset
@pytest.fixture
def mock_telemetry_config_cache():
with mock.patch("mlflow.server.handlers._telemetry_config_cache", {}) as m:
yield m
@pytest.fixture
def bypass_telemetry_env_check(monkeypatch):
monkeypatch.setattr(mlflow.telemetry.utils, "_IS_MLFLOW_TESTING_TELEMETRY", False)
monkeypatch.setattr(mlflow.telemetry.utils, "_IS_IN_CI_ENV_OR_TESTING", False)
monkeypatch.setattr(mlflow.telemetry.utils, "_IS_MLFLOW_DEV_VERSION", False)
@pytest.fixture
def mock_job_store():
with mock.patch("mlflow.server.handlers._get_job_store") as m:
mock_store = mock.MagicMock()
m.return_value = mock_store
yield mock_store
def _create_mock_job(
job_id="job-123",
job_name="optimize_prompts",
status_name="PENDING",
params=None,
result=None,
creation_time=1234567890000,
status_details=None,
):
from mlflow.entities._job import Job
from mlflow.entities._job_status import JobStatus
if params is None:
params = {
"experiment_id": "exp-123",
"prompt_uri": "prompts:/my-prompt/1",
"run_id": "run-456",
}
return Job(
job_id=job_id,
creation_time=creation_time,
job_name=job_name,
params=json.dumps(params),
timeout=None,
status=JobStatus.from_str(status_name),
result=json.dumps(result) if result and status_name == "SUCCEEDED" else result,
retry_count=0,
last_update_time=creation_time,
status_details=status_details,
)
def _create_mock_run(run_id="run-456", params=None, metrics=None):
mock_run = mock.MagicMock()
mock_run.info.run_id = run_id
mock_run.data.params = params or {}
mock_run.data.metrics = metrics or {}
return mock_run
def test_health():
with app.test_client() as c:
response = c.get("/health")
assert response.status_code == 200
assert response.get_data().decode() == "OK"
def test_version():
with app.test_client() as c:
response = c.get("/version")
assert response.status_code == 200
assert response.get_data().decode() == mlflow.__version__
def test_server_info():
with app.test_client() as c:
response = c.get("/api/3.0/mlflow/server-info")
assert response.status_code == 200
data = response.get_json()
assert data["store_type"] == "SqlStore"
assert data["workspaces_enabled"] is False
assert data["trace_archival_enabled"] is False
def test_server_info_trace_archival_enabled(monkeypatch):
monkeypatch.setattr(
"mlflow.server.handlers.get_trace_archival_server_config",
mock.Mock(return_value=mock.Mock(enabled=True)),
)
monkeypatch.setattr("mlflow.server.handlers._store_supports_trace_archival", lambda store: True)
with app.test_client() as c:
response = c.get("/api/3.0/mlflow/server-info")
assert response.status_code == 200
data = response.get_json()
assert data["trace_archival_enabled"] is True
def test_server_info_handles_invalid_trace_archival_config(monkeypatch):
monkeypatch.setattr(
"mlflow.server.handlers.get_trace_archival_server_config",
mock.Mock(
side_effect=MlflowException.invalid_parameter_value("invalid trace archival config")
),
)
with app.test_client() as c:
response = c.get("/api/3.0/mlflow/server-info")
assert response.status_code == 200
data = response.get_json()
assert data["trace_archival_enabled"] is False
def test_server_info_handles_unexpected_trace_archival_config_error(monkeypatch):
monkeypatch.setattr(
"mlflow.server.handlers.get_trace_archival_server_config",
mock.Mock(side_effect=RuntimeError("unexpected trace archival config error")),
)
with app.test_client() as c:
response = c.get("/api/3.0/mlflow/server-info")
assert response.status_code == 200
data = response.get_json()
assert data["trace_archival_enabled"] is False
def test_get_endpoints():
endpoints = get_endpoints()
create_experiment_endpoint = [e for e in endpoints if e[1] == _create_experiment]
assert len(create_experiment_endpoint) == 2
def test_convert_path_parameter_to_flask_format():
converted = _convert_path_parameter_to_flask_format("/mlflow/trace")
assert "/mlflow/trace" == converted
converted = _convert_path_parameter_to_flask_format("/mlflow/trace/{request_id}")
assert "/mlflow/trace/<request_id>" == converted
converted = _convert_path_parameter_to_flask_format("/mlflow/{foo}/{bar}/{baz}")
assert "/mlflow/<foo>/<bar>/<baz>" == converted
def test_all_model_registry_endpoints_available():
endpoints = {handler: method for (path, handler, method) in get_endpoints()}
# Test that each of the handler is enabled as an endpoint with appropriate method.
expected_endpoints = {
"POST": [
_create_registered_model,
_create_model_version,
_rename_registered_model,
_transition_stage,
],
"PATCH": [_update_registered_model, _update_model_version],
"DELETE": [_delete_registered_model, _delete_registered_model],
"GET": [
_search_model_versions,
_get_latest_versions,
_get_registered_model,
_get_model_version,
_get_model_version_download_uri,
],
}
# TODO: efficient mechanism to test endpoint path
for method, handlers in expected_endpoints.items():
for handler in handlers:
assert handler in endpoints
assert endpoints[handler] == [method]
def test_can_parse_json():
request = mock.MagicMock()
request.method = "POST"
request.content_type = "application/json"
request.get_json = mock.MagicMock()
request.get_json.return_value = {"name": "hello"}
msg = _get_request_message(CreateExperiment(), flask_request=request)
assert msg.name == "hello"
def test_can_parse_post_json_with_unknown_fields():
request = mock.MagicMock()
request.method = "POST"
request.content_type = "application/json"
request.get_json = mock.MagicMock()
request.get_json.return_value = {"name": "hello", "WHAT IS THIS FIELD EVEN": "DOING"}
msg = _get_request_message(CreateExperiment(), flask_request=request)
assert msg.name == "hello"
def test_can_parse_post_json_with_content_type_params():
request = mock.MagicMock()
request.method = "POST"
request.content_type = "application/json; charset=utf-8"
request.get_json = mock.MagicMock()
request.get_json.return_value = {"name": "hello"}
msg = _get_request_message(CreateExperiment(), flask_request=request)
assert msg.name == "hello"
def test_can_parse_get_json_with_unknown_fields():
request = mock.MagicMock()
request.method = "GET"
request.args = {"name": "hello", "superDuperUnknown": "field"}
msg = _get_request_message(CreateExperiment(), flask_request=request)
assert msg.name == "hello"
# Previous versions of the client sent a doubly string encoded JSON blob,
# so this test ensures continued compliance with such clients.
def test_can_parse_json_string():
request = mock.MagicMock()
request.method = "POST"
request.content_type = "application/json"
request.get_json = mock.MagicMock()
request.get_json.return_value = '{"name": "hello2"}'
msg = _get_request_message(CreateExperiment(), flask_request=request)
assert msg.name == "hello2"
def test_can_block_post_request_with_invalid_content_type():
request = mock.MagicMock()
request.method = "POST"
request.content_type = "text/plain"
request.get_json = mock.MagicMock()
request.get_json.return_value = {"name": "hello"}
with pytest.raises(MlflowException, match=r"Bad Request. Content-Type"):
_get_request_message(CreateExperiment(), flask_request=request)
def test_can_block_post_request_with_missing_content_type():
request = mock.MagicMock()
request.method = "POST"
request.content_type = None
request.get_json = mock.MagicMock()
request.get_json.return_value = {"name": "hello"}
with pytest.raises(MlflowException, match=r"Bad Request. Content-Type"):
_get_request_message(CreateExperiment(), flask_request=request)
def test_search_runs_default_view_type(mock_get_request_message, mock_tracking_store):
"""
Search Runs default view type is filled in as ViewType.ACTIVE_ONLY
"""
mock_get_request_message.return_value = SearchRuns(experiment_ids=["0"])
mock_tracking_store.search_runs.return_value = PagedList([], None)
_search_runs()
_, kwargs = mock_tracking_store.search_runs.call_args
assert kwargs["run_view_type"] == ViewType.ACTIVE_ONLY
def test_search_runs_empty_page_token(mock_get_request_message, mock_tracking_store):
"""
Test that empty page_token from protobuf is converted to None before calling store
"""
# Create proto without setting page_token
search_runs_proto = SearchRuns()
search_runs_proto.experiment_ids.append("0")
search_runs_proto.max_results = 10
# Verify protobuf returns empty string for unset field
assert search_runs_proto.page_token == ""
mock_get_request_message.return_value = search_runs_proto
mock_tracking_store.search_runs.return_value = PagedList([], None)
_search_runs()
# Verify store was called with None, not empty string
mock_tracking_store.search_runs.assert_called_once()
call_kwargs = mock_tracking_store.search_runs.call_args.kwargs
assert call_kwargs["page_token"] is None # page_token should be None, not ""
def test_log_batch_api_req(mock_get_request_json):
mock_get_request_json.return_value = "a" * (MAX_BATCH_LOG_REQUEST_SIZE + 1)
response = _log_batch()
assert response.status_code == 400
json_response = json.loads(response.get_data())
assert json_response["error_code"] == ErrorCode.Name(INVALID_PARAMETER_VALUE)
assert (
f"Batched logging API requests must be at most {MAX_BATCH_LOG_REQUEST_SIZE} bytes"
in json_response["message"]
)
def test_catch_mlflow_exception():
@catch_mlflow_exception
def test_handler():
raise MlflowException("test error", error_code=INTERNAL_ERROR)
response = test_handler()
json_response = json.loads(response.get_data())
assert response.status_code == 500
assert json_response["error_code"] == ErrorCode.Name(INTERNAL_ERROR)
assert json_response["message"] == "test error"
def test_mlflow_server_with_installed_plugin(tmp_path, monkeypatch):
pytest.skip("FileStore is no longer supported.")
from mlflow_test_plugin.file_store import PluginFileStore
monkeypatch.setenv(BACKEND_STORE_URI_ENV_VAR, f"file-plugin:{tmp_path}")
monkeypatch.setattr(mlflow.server.handlers, "_tracking_store", None)
plugin_file_store = mlflow.server.handlers._get_tracking_store()
assert isinstance(plugin_file_store, PluginFileStore)
assert plugin_file_store.is_plugin
def jsonify(obj):
def _jsonify(obj):
return json.loads(message_to_json(obj.to_proto()))
if isinstance(obj, list):
return [_jsonify(o) for o in obj]
else:
return _jsonify(obj)
# Tests for Model Registry handlers
def test_create_registered_model(mock_get_request_message, mock_model_registry_store):
tags = [
RegisteredModelTag(key="key", value="value"),
RegisteredModelTag(key="anotherKey", value="some other value"),
]
mock_get_request_message.return_value = CreateRegisteredModel(
name="model_1", tags=[tag.to_proto() for tag in tags]
)
rm = RegisteredModel("model_1", tags=tags)
mock_model_registry_store.create_registered_model.return_value = rm
resp = _create_registered_model()
_, args = mock_model_registry_store.create_registered_model.call_args
assert args["name"] == "model_1"
assert {tag.key: tag.value for tag in args["tags"]} == {tag.key: tag.value for tag in tags}
assert json.loads(resp.get_data()) == {"registered_model": jsonify(rm)}
def test_get_registered_model(mock_get_request_message, mock_model_registry_store):
name = "model1"
mock_get_request_message.return_value = GetRegisteredModel(name=name)
rmd = RegisteredModel(
name=name,
creation_timestamp=111,
last_updated_timestamp=222,
description="Test model",
latest_versions=[],
)
mock_model_registry_store.get_registered_model.return_value = rmd
resp = _get_registered_model()
_, args = mock_model_registry_store.get_registered_model.call_args
assert args == {"name": name}
assert json.loads(resp.get_data()) == {"registered_model": jsonify(rmd)}
def test_update_registered_model(mock_get_request_message, mock_model_registry_store):
name = "model_1"
description = "Test model"
mock_get_request_message.return_value = UpdateRegisteredModel(
name=name, description=description
)
rm2 = RegisteredModel(name, description=description)
mock_model_registry_store.update_registered_model.return_value = rm2
resp = _update_registered_model()
_, args = mock_model_registry_store.update_registered_model.call_args
assert args == {"name": name, "description": "Test model"}
assert json.loads(resp.get_data()) == {"registered_model": jsonify(rm2)}
def test_rename_registered_model(mock_get_request_message, mock_model_registry_store):
name = "model_1"
new_name = "model_2"
mock_get_request_message.return_value = RenameRegisteredModel(name=name, new_name=new_name)
rm2 = RegisteredModel(new_name)
mock_model_registry_store.rename_registered_model.return_value = rm2
resp = _rename_registered_model()
_, args = mock_model_registry_store.rename_registered_model.call_args
assert args == {"name": name, "new_name": new_name}
assert json.loads(resp.get_data()) == {"registered_model": jsonify(rm2)}
def test_delete_registered_model(mock_get_request_message, mock_model_registry_store):
name = "model_1"
mock_get_request_message.return_value = DeleteRegisteredModel(name=name)
_delete_registered_model()
_, args = mock_model_registry_store.delete_registered_model.call_args
assert args == {"name": name}
def test_search_registered_models(mock_get_request_message, mock_model_registry_store):
rmds = [
RegisteredModel(
name="model_1",
creation_timestamp=111,
last_updated_timestamp=222,
description="Test model",
latest_versions=[],
),
RegisteredModel(
name="model_2",
creation_timestamp=111,
last_updated_timestamp=333,
description="Another model",
latest_versions=[],
),
]
mock_get_request_message.return_value = SearchRegisteredModels()
mock_model_registry_store.search_registered_models.return_value = PagedList(rmds, None)
resp = _search_registered_models()
_, args = mock_model_registry_store.search_registered_models.call_args
assert args == {
"filter_string": "",
"max_results": SEARCH_REGISTERED_MODEL_MAX_RESULTS_DEFAULT,
"order_by": [],
"page_token": None,
}
assert json.loads(resp.get_data()) == {"registered_models": jsonify(rmds)}
mock_get_request_message.return_value = SearchRegisteredModels(filter="hello")
mock_model_registry_store.search_registered_models.return_value = PagedList(rmds[:1], "tok")
resp = _search_registered_models()
_, args = mock_model_registry_store.search_registered_models.call_args
assert args == {
"filter_string": "hello",
"max_results": SEARCH_REGISTERED_MODEL_MAX_RESULTS_DEFAULT,
"order_by": [],
"page_token": None,
}
assert json.loads(resp.get_data()) == {
"registered_models": jsonify(rmds[:1]),
"next_page_token": "tok",
}
mock_get_request_message.return_value = SearchRegisteredModels(filter="hi", max_results=5)
mock_model_registry_store.search_registered_models.return_value = PagedList([rmds[0]], "tik")
resp = _search_registered_models()
_, args = mock_model_registry_store.search_registered_models.call_args
assert args == {"filter_string": "hi", "max_results": 5, "order_by": [], "page_token": None}
assert json.loads(resp.get_data()) == {
"registered_models": jsonify([rmds[0]]),
"next_page_token": "tik",
}
mock_get_request_message.return_value = SearchRegisteredModels(
filter="hey", max_results=500, order_by=["a", "B desc"], page_token="prev"
)
mock_model_registry_store.search_registered_models.return_value = PagedList(rmds, "DONE")
resp = _search_registered_models()
_, args = mock_model_registry_store.search_registered_models.call_args
assert args == {
"filter_string": "hey",
"max_results": 500,
"order_by": ["a", "B desc"],
"page_token": "prev",
}
assert json.loads(resp.get_data()) == {
"registered_models": jsonify(rmds),
"next_page_token": "DONE",
}
def test_get_latest_versions(mock_get_request_message, mock_model_registry_store):
name = "model1"
mock_get_request_message.return_value = GetLatestVersions(name=name)
mvds = [
ModelVersion(
name=name,
version="5",
creation_timestamp=1,
last_updated_timestamp=12,
description="v 5",
user_id="u1",
current_stage="Production",
source="A/B",
run_id=uuid.uuid4().hex,
status="READY",
status_message=None,
),
ModelVersion(
name=name,
version="1",
creation_timestamp=1,
last_updated_timestamp=1200,
description="v 1",
user_id="u1",
current_stage="Archived",
source="A/B2",
run_id=uuid.uuid4().hex,
status="READY",
status_message=None,
),
ModelVersion(
name=name,
version="12",
creation_timestamp=100,
last_updated_timestamp=None,
description="v 12",
user_id="u2",
current_stage="Staging",
source="A/B3",
run_id=uuid.uuid4().hex,
status="READY",
status_message=None,
),
]
mock_model_registry_store.get_latest_versions.return_value = mvds
resp = _get_latest_versions()
_, args = mock_model_registry_store.get_latest_versions.call_args
assert args == {"name": name, "stages": []}
assert json.loads(resp.get_data()) == {"model_versions": jsonify(mvds)}
for stages in [[], ["None"], ["Staging"], ["Staging", "Production"]]:
mock_get_request_message.return_value = GetLatestVersions(name=name, stages=stages)
_get_latest_versions()
_, args = mock_model_registry_store.get_latest_versions.call_args
assert args == {"name": name, "stages": stages}
def test_create_model_version(mock_get_request_message, mock_model_registry_store):
run_id = uuid.uuid4().hex
tags = [
ModelVersionTag(key="key", value="value"),
ModelVersionTag(key="anotherKey", value="some other value"),
]
run_link = "localhost:5000/path/to/run"
mock_get_request_message.return_value = CreateModelVersion(
name="model_1",
source=f"runs:/{run_id}",
run_id=run_id,
run_link=run_link,
tags=[tag.to_proto() for tag in tags],
)
mv = ModelVersion(
name="model_1", version="12", creation_timestamp=123, tags=tags, run_link=run_link
)
mock_model_registry_store.create_model_version.return_value = mv
resp = _create_model_version()
_, args = mock_model_registry_store.create_model_version.call_args
assert args["name"] == "model_1"
assert args["source"] == f"runs:/{run_id}"
assert args["run_id"] == run_id
assert {tag.key: tag.value for tag in args["tags"]} == {tag.key: tag.value for tag in tags}
assert args["run_link"] == run_link
assert json.loads(resp.get_data()) == {"model_version": jsonify(mv)}
@pytest.mark.parametrize(
"source",
[
"file:///etc/passwd",
"file:///",
"/etc/passwd",
"file:///proc/self/environ",
"file://remote-host/etc/passwd",
"file://remote-host/",
],
)
def test_create_model_version_rejects_local_source_for_prompts(
mock_get_request_message, mock_model_registry_store, source
):
mock_get_request_message.return_value = CreateModelVersion(
name="model_1",
source=source,
tags=[ModelVersionTag(key=IS_PROMPT_TAG_KEY, value="true").to_proto()],
)
resp = _create_model_version()
assert resp.status_code == 400
assert "Invalid prompt source" in resp.get_json()["message"]
@pytest.mark.parametrize(
"source",
[
"https://example.com/../../etc/passwd",
"http://example.com/path/..%2f..%2fsecret",
],
)
def test_create_model_version_rejects_traversal_source_for_prompts(
mock_get_request_message, mock_model_registry_store, source
):
mock_get_request_message.return_value = CreateModelVersion(
name="model_1",
source=source,
tags=[ModelVersionTag(key=IS_PROMPT_TAG_KEY, value="true").to_proto()],
)
resp = _create_model_version()
assert resp.status_code == 400
assert "Invalid model version source" in resp.get_json()["message"]
def test_set_registered_model_tag(mock_get_request_message, mock_model_registry_store):
name = "model1"
tag = RegisteredModelTag(key="some weird key", value="some value")
mock_get_request_message.return_value = SetRegisteredModelTag(
name=name, key=tag.key, value=tag.value
)
_set_registered_model_tag()
_, args = mock_model_registry_store.set_registered_model_tag.call_args
assert args == {"name": name, "tag": tag}
def test_delete_registered_model_tag(mock_get_request_message, mock_model_registry_store):
name = "model1"
key = "some weird key"
mock_get_request_message.return_value = DeleteRegisteredModelTag(name=name, key=key)
_delete_registered_model_tag()
_, args = mock_model_registry_store.delete_registered_model_tag.call_args
assert args == {"name": name, "key": key}
def test_get_model_version_details(mock_get_request_message, mock_model_registry_store):
mock_get_request_message.return_value = GetModelVersion(name="model1", version="32")
mvd = ModelVersion(
name="model1",
version="5",
creation_timestamp=1,
last_updated_timestamp=12,
description="v 5",
user_id="u1",
current_stage="Production",
source="A/B",
run_id=uuid.uuid4().hex,
status="READY",
status_message=None,
)
mock_model_registry_store.get_model_version.return_value = mvd
resp = _get_model_version()
_, args = mock_model_registry_store.get_model_version.call_args
assert args == {"name": "model1", "version": "32"}
assert json.loads(resp.get_data()) == {"model_version": jsonify(mvd)}
def test_update_model_version(mock_get_request_message, mock_model_registry_store):
name = "model1"
version = "32"
description = "Great model!"
mock_get_request_message.return_value = UpdateModelVersion(
name=name, version=version, description=description
)
mv = ModelVersion(name=name, version=version, creation_timestamp=123, description=description)
mock_model_registry_store.update_model_version.return_value = mv
_update_model_version()
_, args = mock_model_registry_store.update_model_version.call_args
assert args == {"name": name, "version": version, "description": description}
def test_transition_model_version_stage(mock_get_request_message, mock_model_registry_store):
name = "model1"
version = "32"
stage = "Production"
mock_get_request_message.return_value = TransitionModelVersionStage(
name=name, version=version, stage=stage
)
mv = ModelVersion(name=name, version=version, creation_timestamp=123, current_stage=stage)
mock_model_registry_store.transition_model_version_stage.return_value = mv
_transition_stage()
_, args = mock_model_registry_store.transition_model_version_stage.call_args
assert args == {
"name": name,
"version": version,
"stage": stage,
"archive_existing_versions": False,
}
def test_delete_model_version(mock_get_request_message, mock_model_registry_store):
name = "model1"
version = "32"
mock_get_request_message.return_value = DeleteModelVersion(name=name, version=version)
_delete_model_version()
_, args = mock_model_registry_store.delete_model_version.call_args
assert args == {"name": name, "version": version}
def test_get_model_version_download_uri(mock_get_request_message, mock_model_registry_store):
name = "model1"
version = "32"
mock_get_request_message.return_value = GetModelVersionDownloadUri(name=name, version=version)
mock_model_registry_store.get_model_version_download_uri.return_value = "some/download/path"
resp = _get_model_version_download_uri()
_, args = mock_model_registry_store.get_model_version_download_uri.call_args
assert args == {"name": name, "version": version}
assert json.loads(resp.get_data()) == {"artifact_uri": "some/download/path"}
def test_search_model_versions(mock_get_request_message, mock_model_registry_store):
mvds = [
ModelVersion(
name="model_1",
version="5",
creation_timestamp=100,
last_updated_timestamp=3200,
description="v 5",
user_id="u1",
current_stage="Production",
source="A/B/CD",
run_id=uuid.uuid4().hex,
status="READY",
status_message=None,
),
ModelVersion(
name="model_1",
version="12",
creation_timestamp=110,
last_updated_timestamp=2000,
description="v 12",
user_id="u2",
current_stage="Production",
source="A/B/CD",
run_id=uuid.uuid4().hex,
status="READY",
status_message=None,
),
ModelVersion(
name="ads_model",
version="8",
creation_timestamp=200,
last_updated_timestamp=1000,
description="v 8",
user_id="u1",
current_stage="Staging",
source="A/B/CD",
run_id=uuid.uuid4().hex,
status="READY",
status_message=None,
),
ModelVersion(
name="fraud_detection_model",
version="345",
creation_timestamp=1000,
last_updated_timestamp=999,
description="newest version",
user_id="u12",
current_stage="None",
source="A/B/CD",
run_id=uuid.uuid4().hex,
status="READY",
status_message=None,
),
]
mock_get_request_message.return_value = SearchModelVersions(filter="source_path = 'A/B/CD'")
mock_model_registry_store.search_model_versions.return_value = PagedList(mvds, None)
resp = _search_model_versions()
mock_model_registry_store.search_model_versions.assert_called_with(
filter_string="source_path = 'A/B/CD'",
max_results=SEARCH_MODEL_VERSION_MAX_RESULTS_THRESHOLD,
order_by=[],
page_token=None,
)
assert json.loads(resp.get_data()) == {"model_versions": jsonify(mvds)}
mock_get_request_message.return_value = SearchModelVersions(filter="name='model_1'")
mock_model_registry_store.search_model_versions.return_value = PagedList(mvds[:1], "tok")
resp = _search_model_versions()
mock_model_registry_store.search_model_versions.assert_called_with(
filter_string="name='model_1'",
max_results=SEARCH_MODEL_VERSION_MAX_RESULTS_THRESHOLD,
order_by=[],
page_token=None,
)
assert json.loads(resp.get_data()) == {
"model_versions": jsonify(mvds[:1]),
"next_page_token": "tok",
}
mock_get_request_message.return_value = SearchModelVersions(filter="version<=12", max_results=2)
mock_model_registry_store.search_model_versions.return_value = PagedList(
[mvds[0], mvds[2]], "next"
)
resp = _search_model_versions()
mock_model_registry_store.search_model_versions.assert_called_with(
filter_string="version<=12", max_results=2, order_by=[], page_token=None
)
assert json.loads(resp.get_data()) == {
"model_versions": jsonify([mvds[0], mvds[2]]),
"next_page_token": "next",
}
mock_get_request_message.return_value = SearchModelVersions(
filter="version<=12", max_results=2, order_by=["version DESC"], page_token="prev"
)
mock_model_registry_store.search_model_versions.return_value = PagedList(mvds[1:3], "next")
resp = _search_model_versions()
mock_model_registry_store.search_model_versions.assert_called_with(
filter_string="version<=12", max_results=2, order_by=["version DESC"], page_token="prev"
)
assert json.loads(resp.get_data()) == {
"model_versions": jsonify(mvds[1:3]),
"next_page_token": "next",
}
def test_set_model_version_tag(mock_get_request_message, mock_model_registry_store):
name = "model1"
version = "1"
tag = ModelVersionTag(key="some weird key", value="some value")
mock_get_request_message.return_value = SetModelVersionTag(
name=name, version=version, key=tag.key, value=tag.value
)
_set_model_version_tag()
_, args = mock_model_registry_store.set_model_version_tag.call_args
assert args == {"name": name, "version": version, "tag": tag}
def test_delete_model_version_tag(mock_get_request_message, mock_model_registry_store):
name = "model1"
version = "1"
key = "some weird key"
mock_get_request_message.return_value = DeleteModelVersionTag(
name=name, version=version, key=key
)
_delete_model_version_tag()
_, args = mock_model_registry_store.delete_model_version_tag.call_args
assert args == {"name": name, "version": version, "key": key}
def test_set_registered_model_alias(mock_get_request_message, mock_model_registry_store):
name = "model1"
alias = "test_alias"
version = "1"
mock_get_request_message.return_value = SetRegisteredModelAlias(
name=name, alias=alias, version=version
)
_set_registered_model_alias()
_, args = mock_model_registry_store.set_registered_model_alias.call_args
assert args == {"name": name, "alias": alias, "version": version}
def test_delete_registered_model_alias(mock_get_request_message, mock_model_registry_store):
name = "model1"
alias = "test_alias"
mock_get_request_message.return_value = DeleteRegisteredModelAlias(name=name, alias=alias)
_delete_registered_model_alias()
_, args = mock_model_registry_store.delete_registered_model_alias.call_args
assert args == {"name": name, "alias": alias}
def test_get_model_version_by_alias(mock_get_request_message, mock_model_registry_store):
name = "model1"
alias = "test_alias"
mock_get_request_message.return_value = GetModelVersionByAlias(name=name, alias=alias)
mvd = ModelVersion(
name="model1",
version="5",
creation_timestamp=1,
last_updated_timestamp=12,
description="v 5",
user_id="u1",
current_stage="Production",
source="A/B",
run_id=uuid.uuid4().hex,
status="READY",
status_message=None,
aliases=["test_alias"],
)
mock_model_registry_store.get_model_version_by_alias.return_value = mvd
resp = _get_model_version_by_alias()
_, args = mock_model_registry_store.get_model_version_by_alias.call_args
assert args == {"name": name, "alias": alias}
assert json.loads(resp.get_data()) == {"model_version": jsonify(mvd)}
@pytest.mark.parametrize(
"path",
[
"/path",
"path/../to/file",
"/etc/passwd",
"/etc/passwd%00.jpg",
"/file://etc/passwd",
"%2E%2E%2F%2E%2E%2Fpath",
],
)
def test_delete_artifact_mlflow_artifacts_throws_for_malicious_path(enable_serve_artifacts, path):
response = _delete_artifact_mlflow_artifacts(path)
assert response.status_code == 400
json_response = json.loads(response.get_data())
assert json_response["error_code"] == ErrorCode.Name(INVALID_PARAMETER_VALUE)
assert json_response["message"] == "Invalid path"
def test_get_presigned_download_url_success(enable_serve_artifacts):
from mlflow.store.artifact.artifact_repo import MultipartDownloadMixin
class MockMultipartDownloadRepo(MultipartDownloadMixin):
def get_download_presigned_url(self, artifact_path, expiration=300):
return PresignedDownloadUrlResponse(
url="https://storage.example.com/presigned?token=abc",
headers={"x-custom-header": "value"},
file_size=1024,
)
artifact_path = "run_id/artifacts/model.pkl"
with (
app.test_request_context(method="GET"),
mock.patch(
"mlflow.server.handlers._get_artifact_repo_mlflow_artifacts",
return_value=MockMultipartDownloadRepo(),
),
):
response = _get_presigned_download_url(artifact_path)
assert response.status_code == 200
data = json.loads(response.get_data())
assert data["url"] == "https://storage.example.com/presigned?token=abc"
assert data["headers"] == {"x-custom-header": "value"}
assert data["file_size"] == 1024
@pytest.mark.parametrize(
"path",
[
"/path",
"path/../to/file",
"/etc/passwd",
"/etc/passwd%00.jpg",
"/file://etc/passwd",
"%2E%2E%2F%2E%2E%2Fpath",
],
)
def test_get_presigned_download_url_throws_for_malicious_path(enable_serve_artifacts, path):
response = _get_presigned_download_url(path)
assert response.status_code == 400
json_response = json.loads(response.get_data())
assert json_response["error_code"] == ErrorCode.Name(INVALID_PARAMETER_VALUE)
assert json_response["message"] == "Invalid path"
def test_get_presigned_download_url_unsupported_repo(enable_serve_artifacts, tmp_path):
with (
app.test_request_context(method="GET"),
mock.patch(
"mlflow.server.handlers._get_artifact_repo_mlflow_artifacts",
return_value=LocalArtifactRepository(str(tmp_path)),
),
):
response = _get_presigned_download_url("some/artifact/path")
assert response.status_code == 501
json_response = json.loads(response.get_data())
assert json_response["error_code"] == ErrorCode.Name(NOT_IMPLEMENTED)
assert "multipart" in json_response["message"].lower()
# --- Presigned upload URL handler tests ---
def test_create_presigned_upload_url_success():
from mlflow.store.artifact.artifact_repo import PresignedUploadMixin
class MockPresignedUploadRepo(PresignedUploadMixin):
def create_presigned_upload_url(self, artifact_path, expiration=900):
return CreatePresignedUploadResponse(
presigned_url="https://s3.amazonaws.com/bucket/artifacts/model.pkl?X-Amz-Signature=abc",
headers={"Content-Type": "application/octet-stream"},
)
mock_run = mock.MagicMock()
mock_run.info.artifact_uri = "s3://bucket/0/abc123/artifacts"
from mlflow.protos.service_pb2 import CreatePresignedUploadUrl
request_proto = CreatePresignedUploadUrl()
request_proto.run_id = "abc123"
request_proto.path = "model.pkl"
with (
app.test_request_context(method="POST", content_type="application/json"),
mock.patch(
"mlflow.server.handlers._get_request_message",
return_value=request_proto,
),
mock.patch(
"mlflow.server.handlers._get_tracking_store",
) as mock_store,
mock.patch(
"mlflow.server.handlers._get_artifact_repo",
return_value=MockPresignedUploadRepo(),
),
):
mock_store.return_value.get_run.return_value = mock_run
response = _create_presigned_upload_url()
assert response.status_code == 200
data = json.loads(response.get_data())
assert "presigned_url" in data
assert "X-Amz-Signature" in data["presigned_url"]
assert data["headers"] == {"Content-Type": "application/octet-stream"}
def test_create_presigned_upload_url_unsupported_repo():
mock_run = mock.MagicMock()
mock_run.info.artifact_uri = "file:///tmp/artifacts"
from mlflow.protos.service_pb2 import CreatePresignedUploadUrl
request_proto = CreatePresignedUploadUrl()
request_proto.run_id = "abc123"
request_proto.path = "model.pkl"
with (
app.test_request_context(method="POST", content_type="application/json"),
mock.patch(
"mlflow.server.handlers._get_request_message",
return_value=request_proto,
),
mock.patch(
"mlflow.server.handlers._get_tracking_store",
) as mock_store,
mock.patch(
"mlflow.server.handlers._get_artifact_repo",
return_value=LocalArtifactRepository("/tmp/artifacts"),
),
):
mock_store.return_value.get_run.return_value = mock_run
response = _create_presigned_upload_url()
assert response.status_code == 501
json_response = json.loads(response.get_data())
assert json_response["error_code"] == ErrorCode.Name(NOT_IMPLEMENTED)
assert "presigned upload" in json_response["message"].lower()
@pytest.mark.parametrize(
"artifact_uri",
[
"mlflow-artifacts:/0/abc123/artifacts",
"http://mlflow-server:5000/api/2.0/mlflow-artifacts/artifacts",
"https://mlflow-server/api/2.0/mlflow-artifacts/artifacts",
],
)
def test_create_presigned_upload_url_rejects_proxy_artifact_uri(artifact_uri):
mock_run = mock.MagicMock()
mock_run.info.artifact_uri = artifact_uri
from mlflow.protos.service_pb2 import CreatePresignedUploadUrl
request_proto = CreatePresignedUploadUrl()
request_proto.run_id = "abc123"
request_proto.path = "model.pkl"
with (
app.test_request_context(method="POST", content_type="application/json"),
mock.patch(
"mlflow.server.handlers._get_request_message",
return_value=request_proto,
),
mock.patch(
"mlflow.server.handlers._get_tracking_store",
) as mock_store,
):
mock_store.return_value.get_run.return_value = mock_run
response = _create_presigned_upload_url()
assert response.status_code == 400
json_response = json.loads(response.get_data())
assert json_response["error_code"] == ErrorCode.Name(INVALID_PARAMETER_VALUE)
assert "proxied" in json_response["message"].lower()
def test_create_presigned_upload_url_invalid_run_id():
from mlflow.protos.service_pb2 import CreatePresignedUploadUrl
request_proto = CreatePresignedUploadUrl()
request_proto.run_id = "nonexistent_run"
request_proto.path = "model.pkl"
with (
app.test_request_context(method="POST", content_type="application/json"),
mock.patch(
"mlflow.server.handlers._get_request_message",
return_value=request_proto,
),
mock.patch(
"mlflow.server.handlers._get_tracking_store",
) as mock_store,
):
mock_store.return_value.get_run.side_effect = MlflowException(
"Run 'nonexistent_run' not found",
error_code=RESOURCE_DOES_NOT_EXIST,
)
response = _create_presigned_upload_url()
assert response.status_code == 404
json_response = json.loads(response.get_data())
assert json_response["error_code"] == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
@pytest.mark.parametrize(
"path",
[
"../../../etc/passwd",
"path/../to/file",
"/etc/passwd",
"/etc/passwd%00.jpg",
"%2E%2E%2F%2E%2E%2Fpath",
],
)
def test_create_presigned_upload_url_rejects_path_traversal(path):
from mlflow.protos.service_pb2 import CreatePresignedUploadUrl
request_proto = CreatePresignedUploadUrl()
request_proto.run_id = "abc123"
request_proto.path = path
with (
app.test_request_context(method="POST", content_type="application/json"),
mock.patch(
"mlflow.server.handlers._get_request_message",
return_value=request_proto,
),
):
response = _create_presigned_upload_url()
assert response.status_code == 400
json_response = json.loads(response.get_data())
assert json_response["error_code"] == ErrorCode.Name(INVALID_PARAMETER_VALUE)
def test_create_presigned_upload_url_with_custom_expiration():
from mlflow.store.artifact.artifact_repo import PresignedUploadMixin
captured_expiration = {}
class MockPresignedUploadRepo(PresignedUploadMixin):
def create_presigned_upload_url(self, artifact_path, expiration=900):
captured_expiration["value"] = expiration
return CreatePresignedUploadResponse(
presigned_url="https://example.com/presigned",
headers={},
)
mock_run = mock.MagicMock()
mock_run.info.artifact_uri = "s3://bucket/0/abc123/artifacts"
from mlflow.protos.service_pb2 import CreatePresignedUploadUrl
request_proto = CreatePresignedUploadUrl()
request_proto.run_id = "abc123"
request_proto.path = "model.pkl"
request_proto.expiration = 60
with (
app.test_request_context(method="POST", content_type="application/json"),
mock.patch(
"mlflow.server.handlers._get_request_message",
return_value=request_proto,
),
mock.patch(
"mlflow.server.handlers._get_tracking_store",
) as mock_store,
mock.patch(
"mlflow.server.handlers._get_artifact_repo",
return_value=MockPresignedUploadRepo(),
),
):
mock_store.return_value.get_run.return_value = mock_run
response = _create_presigned_upload_url()
assert response.status_code == 200
assert captured_expiration["value"] == 60
def test_create_presigned_upload_url_default_expiration():
from mlflow.store.artifact.artifact_repo import PresignedUploadMixin
captured_expiration = {}
class MockPresignedUploadRepo(PresignedUploadMixin):
def create_presigned_upload_url(self, artifact_path, expiration=900):
captured_expiration["value"] = expiration
return CreatePresignedUploadResponse(
presigned_url="https://example.com/presigned",
headers={},
)
mock_run = mock.MagicMock()
mock_run.info.artifact_uri = "s3://bucket/0/abc123/artifacts"
from mlflow.protos.service_pb2 import CreatePresignedUploadUrl
# Don't set expiration - should default to 900
request_proto = CreatePresignedUploadUrl()
request_proto.run_id = "abc123"
request_proto.path = "model.pkl"
with (
app.test_request_context(method="POST", content_type="application/json"),
mock.patch(
"mlflow.server.handlers._get_request_message",
return_value=request_proto,
),
mock.patch(
"mlflow.server.handlers._get_tracking_store",
) as mock_store,
mock.patch(
"mlflow.server.handlers._get_artifact_repo",
return_value=MockPresignedUploadRepo(),
),
):
mock_store.return_value.get_run.return_value = mock_run
response = _create_presigned_upload_url()
assert response.status_code == 200
assert captured_expiration["value"] == 900
def test_create_presigned_upload_url_blocked_in_artifacts_only_mode(monkeypatch):
from mlflow.server import ARTIFACTS_ONLY_ENV_VAR
monkeypatch.setenv(ARTIFACTS_ONLY_ENV_VAR, "true")
with app.test_request_context(method="POST", content_type="application/json"):
response = _create_presigned_upload_url()
assert response.status_code == 503
assert "artifacts-only" in response.get_data(as_text=True).lower()
@pytest.mark.parametrize(
"uri",
[
"http://host#/abc/etc/",
"http://host/;..%2F..%2Fetc",
],
)
def test_local_file_read_write_by_pass_vulnerability(uri):
request = mock.MagicMock()
request.method = "POST"
request.content_type = "application/json; charset=utf-8"
request.get_json = mock.MagicMock()
request.get_json.return_value = {
"name": "hello",
"artifact_location": uri,
}
msg = _get_request_message(CreateExperiment(), flask_request=request)
with mock.patch("mlflow.server.handlers._get_request_message", return_value=msg):
response = _create_experiment()
json_response = json.loads(response.get_data())
assert (
json_response["message"] == "'artifact_location' URL can't include fragments or params."
)
# Test if source is a local filesystem path, `_validate_source` validates that the run
# artifact_uri is also a local filesystem path.
run_id = uuid.uuid4().hex
with mock.patch("mlflow.server.handlers._get_tracking_store") as mock_get_tracking_store:
mock_get_tracking_store().get_run(
run_id
).info.artifact_uri = f"http://host/{run_id}/artifacts/abc"
with pytest.raises(
MlflowException,
match=(
"the run_id request parameter has to be specified and the local "
"path has to be contained within the artifact directory of the "
"run specified by the run_id"
),
):
_validate_source_run("/local/path/xyz", run_id)
@pytest.mark.parametrize(
("location", "expected_class", "expected_uri"),
[
("file:///0/traces/123", LocalArtifactRepository, "file:///0/traces/123"),
("s3://bucket/0/traces/123", S3ArtifactRepository, "s3://bucket/0/traces/123"),
(
"wasbs://container@account.blob.core.windows.net/bucket/1/traces/123",
AzureBlobArtifactRepository,
"wasbs://container@account.blob.core.windows.net/bucket/1/traces/123",
),
# Proxy URI must be resolved to the actual storage URI
(
"https://127.0.0.1/api/2.0/mlflow-artifacts/artifacts/2/traces/123",
S3ArtifactRepository,
"s3://bucket/2/traces/123",
),
("mlflow-artifacts:/1/traces/123", S3ArtifactRepository, "s3://bucket/1/traces/123"),
],
)
def test_get_trace_artifact_repo(location, expected_class, expected_uri, monkeypatch):
monkeypatch.setenv(SERVE_ARTIFACTS_ENV_VAR, "true")
monkeypatch.setenv(ARTIFACTS_DESTINATION_ENV_VAR, "s3://bucket")
trace_info = TraceInfo(
trace_id="123",
trace_location=EntityTraceLocation.from_experiment_id("0"),
request_time=0,
execution_duration=1,
state=TraceState.OK,
tags={MLFLOW_ARTIFACT_LOCATION: location},
)
repo = _get_trace_artifact_repo(trace_info)
assert isinstance(repo, expected_class)
assert repo.artifact_uri == expected_uri
### Prompt Registry Tests ###
def test_create_prompt_as_registered_model(mock_get_request_message, mock_model_registry_store):
tags = [RegisteredModelTag(key=IS_PROMPT_TAG_KEY, value="true")]
mock_get_request_message.return_value = CreateRegisteredModel(
name="model_1", tags=[tag.to_proto() for tag in tags]
)
rm = RegisteredModel("model_1", tags=tags)
mock_model_registry_store.create_registered_model.return_value = rm
resp = _create_registered_model()
_, args = mock_model_registry_store.create_registered_model.call_args
assert args["name"] == "model_1"
assert {tag.key: tag.value for tag in args["tags"]} == {tag.key: tag.value for tag in tags}
assert json.loads(resp.get_data()) == {"registered_model": jsonify(rm)}
def test_create_prompt_as_model_version(mock_get_request_message, mock_model_registry_store):
tags = [
ModelVersionTag(key=IS_PROMPT_TAG_KEY, value="true"),
ModelVersionTag(key=PROMPT_TEXT_TAG_KEY, value="some prompt text"),
]
mock_get_request_message.return_value = CreateModelVersion(
name="model_1",
tags=[tag.to_proto() for tag in tags],
source=None,
run_id=None,
run_link=None,
)
mv = ModelVersion(
name="prompt_1", version="12", creation_timestamp=123, tags=tags, run_link=None
)
mock_model_registry_store.create_model_version.return_value = mv
resp = _create_model_version()
_, args = mock_model_registry_store.create_model_version.call_args
assert args["name"] == "model_1"
assert args["source"] == ""
assert args["run_id"] == ""
assert {tag.key: tag.value for tag in args["tags"]} == {tag.key: tag.value for tag in tags}
assert args["run_link"] == ""
assert json.loads(resp.get_data()) == {"model_version": jsonify(mv)}
def test_create_evaluation_dataset(mock_tracking_store, mock_evaluation_dataset):
mock_tracking_store.create_dataset.return_value = mock_evaluation_dataset
with app.test_request_context(
method="POST",
json={
"name": "test_dataset",
"experiment_ids": ["0", "1"],
"tags": json.dumps({"env": "test"}),
},
):
_create_dataset_handler()
mock_tracking_store.create_dataset.assert_called_once_with(
name="test_dataset",
experiment_ids=["0", "1"],
tags={"env": "test"},
)
def test_get_evaluation_dataset(mock_tracking_store, mock_evaluation_dataset):
mock_tracking_store.get_dataset.return_value = mock_evaluation_dataset
dataset_id = "d-1234567890abcdef1234567890abcdef"
with app.test_request_context(method="GET"):
_get_dataset_handler(dataset_id)
mock_tracking_store.get_dataset.assert_called_once_with(dataset_id)
def test_delete_evaluation_dataset(mock_tracking_store):
dataset_id = "d-1234567890abcdef1234567890abcdef"
with app.test_request_context(method="DELETE"):
_delete_dataset_handler(dataset_id)
mock_tracking_store.delete_dataset.assert_called_once_with(dataset_id)
def test_search_datasets(mock_tracking_store):
from mlflow.protos.datasets_pb2 import Dataset as ProtoDataset
datasets = []
for i in range(2):
ds = mock.MagicMock()
ds.name = f"dataset_{i}"
proto = ProtoDataset()
proto.dataset_id = f"d-{i:032d}"
proto.name = ds.name
ds.to_proto.return_value = proto
datasets.append(ds)
paged_list = PagedList(datasets, "next_token")
mock_tracking_store.search_datasets.return_value = paged_list
with app.test_request_context(
method="POST",
json={
"experiment_ids": ["0", "1"],
"filter_string": "name = 'dataset_1'",
"max_results": 10,
"order_by": ["name DESC"],
"page_token": "token123",
},
):
_search_evaluation_datasets_handler()
mock_tracking_store.search_datasets.assert_called_once_with(
experiment_ids=["0", "1"],
filter_string="name = 'dataset_1'",
max_results=10,
order_by=["name DESC"],
page_token="token123",
)
def test_set_dataset_tags(mock_tracking_store):
dataset_id = "d-1234567890abcdef1234567890abcdef"
with app.test_request_context(
method="POST",
json={
"tags": json.dumps({"env": "production", "version": "2.0"}),
},
):
_set_dataset_tags_handler(dataset_id)
mock_tracking_store.set_dataset_tags.assert_called_once_with(
dataset_id=dataset_id,
tags={"env": "production", "version": "2.0"},
)
def test_delete_dataset_tag(mock_tracking_store):
dataset_id = "d-1234567890abcdef1234567890abcdef"
key = "deprecated_tag"
with app.test_request_context(method="DELETE"):
_delete_dataset_tag_handler(dataset_id, key)
mock_tracking_store.delete_dataset_tag.assert_called_once_with(
dataset_id=dataset_id,
key=key,
)
def test_upsert_dataset_records(mock_tracking_store):
mock_tracking_store.upsert_dataset_records.return_value = {
"inserted": 2,
"updated": 0,
}
dataset_id = "d-1234567890abcdef1234567890abcdef"
records = [
{"inputs": {"q": "test1"}, "expectations": {"score": 0.9}},
{"inputs": {"q": "test2"}, "expectations": {"score": 0.8}},
]
with app.test_request_context(
method="POST",
json={
"records": json.dumps(records),
},
):
resp = _upsert_dataset_records_handler(dataset_id)
mock_tracking_store.upsert_dataset_records.assert_called_once_with(
dataset_id=dataset_id,
records=records,
)
response_data = json.loads(resp.get_data())
assert response_data["inserted_count"] == 2
assert response_data["updated_count"] == 0
def test_get_dataset_experiment_ids(mock_tracking_store):
mock_tracking_store.get_dataset_experiment_ids.return_value = [
"exp1",
"exp2",
"exp3",
]
dataset_id = "d-1234567890abcdef1234567890abcdef"
with app.test_request_context(method="GET"):
resp = _get_dataset_experiment_ids_handler(dataset_id)
mock_tracking_store.get_dataset_experiment_ids.assert_called_once_with(dataset_id=dataset_id)
response_data = json.loads(resp.get_data())
assert response_data["experiment_ids"] == ["exp1", "exp2", "exp3"]
def test_get_dataset_records(mock_tracking_store):
records = []
for i in range(3):
record = mock.MagicMock()
record.dataset_id = "d-1234567890abcdef1234567890abcdef"
record.dataset_record_id = f"r-00{i}"
record.inputs = {"question": f"test{i}"}
record.expectations = {"score": 0.9 - i * 0.1}
record.tags = {}
record.created_time = 1234567890 + i
record.last_update_time = 1234567890 + i
record.to_dict.return_value = {
"dataset_id": record.dataset_id,
"dataset_record_id": record.dataset_record_id,
"inputs": record.inputs,
"expectations": record.expectations,
"tags": record.tags,
"created_time": record.created_time,
"last_update_time": record.last_update_time,
}
records.append(record)
mock_tracking_store._load_dataset_records.return_value = (records, None)
dataset_id = "d-1234567890abcdef1234567890abcdef"
with app.test_request_context(method="GET"):
resp = _get_dataset_records_handler(dataset_id)
mock_tracking_store._load_dataset_records.assert_called_with(
dataset_id, max_results=1000, page_token=None
)
response_data = json.loads(resp.get_data())
records_data = json.loads(response_data["records"])
assert len(records_data) == 3
assert records_data[0]["dataset_record_id"] == "r-000"
mock_tracking_store._load_dataset_records.return_value = (records[:2], "token_page2")
with app.test_request_context(
method="GET",
json={
"max_results": 2,
"page_token": None,
},
):
resp = _get_dataset_records_handler(dataset_id)
mock_tracking_store._load_dataset_records.assert_called_with(
dataset_id, max_results=2, page_token=None
)
response_data = json.loads(resp.get_data())
records_data = json.loads(response_data["records"])
assert len(records_data) == 2
assert response_data["next_page_token"] == "token_page2"
mock_tracking_store._load_dataset_records.return_value = (records[2:], None)
with app.test_request_context(
method="GET",
json={
"max_results": 2,
"page_token": "token_page2",
},
):
resp = _get_dataset_records_handler(dataset_id)
mock_tracking_store._load_dataset_records.assert_called_with(
dataset_id, max_results=2, page_token="token_page2"
)
response_data = json.loads(resp.get_data())
records_data = json.loads(response_data["records"])
assert len(records_data) == 1
assert "next_page_token" not in response_data or response_data["next_page_token"] == ""
def test_get_dataset_records_empty(mock_tracking_store):
mock_tracking_store._load_dataset_records.return_value = ([], None)
dataset_id = "d-1234567890abcdef1234567890abcdef"
with app.test_request_context(method="GET"):
resp = _get_dataset_records_handler(dataset_id)
response_data = json.loads(resp.get_data())
records_data = json.loads(response_data["records"])
assert len(records_data) == 0
assert "next_page_token" not in response_data or response_data["next_page_token"] == ""
def test_get_dataset_records_pagination(mock_tracking_store):
dataset_id = "d-1234567890abcdef1234567890abcdef"
all_records = []
for i in range(50):
record = mock.Mock()
record.dataset_record_id = f"r-{i:03d}"
record.inputs = {"q": f"Question {i}"}
record.expectations = {"a": f"Answer {i}"}
record.tags = {}
record.source_type = "TRACE"
record.source_id = f"trace-{i}"
record.created_time = 1609459200 + i
record.to_dict.return_value = {
"dataset_record_id": f"r-{i:03d}",
"inputs": {"q": f"Question {i}"},
"expectations": {"a": f"Answer {i}"},
"tags": {},
"source_type": "TRACE",
"source_id": f"trace-{i}",
"created_time": 1609459200 + i,
}
all_records.append(record)
mock_tracking_store._load_dataset_records.return_value = (all_records[:20], "token_20")
with app.test_request_context(
method="GET",
json={"max_results": 20},
):
resp = _get_dataset_records_handler(dataset_id)
mock_tracking_store._load_dataset_records.assert_called_with(
dataset_id, max_results=20, page_token=None
)
response_data = json.loads(resp.get_data())
records_data = json.loads(response_data["records"])
assert len(records_data) == 20
assert response_data["next_page_token"] == "token_20"
assert records_data[0]["dataset_record_id"] == "r-000"
assert records_data[19]["dataset_record_id"] == "r-019"
mock_tracking_store._load_dataset_records.return_value = (all_records[20:40], "token_40")
with app.test_request_context(
method="GET",
json={"max_results": 20, "page_token": "token_20"},
):
resp = _get_dataset_records_handler(dataset_id)
mock_tracking_store._load_dataset_records.assert_called_with(
dataset_id, max_results=20, page_token="token_20"
)
response_data = json.loads(resp.get_data())
records_data = json.loads(response_data["records"])
assert len(records_data) == 20
assert response_data["next_page_token"] == "token_40"
assert records_data[0]["dataset_record_id"] == "r-020"
mock_tracking_store._load_dataset_records.return_value = (all_records[40:], None)
with app.test_request_context(
method="GET",
json={"max_results": 20, "page_token": "token_40"},
):
resp = _get_dataset_records_handler(dataset_id)
response_data = json.loads(resp.get_data())
records_data = json.loads(response_data["records"])
assert len(records_data) == 10
assert "next_page_token" not in response_data or response_data["next_page_token"] == ""
assert records_data[0]["dataset_record_id"] == "r-040"
assert records_data[9]["dataset_record_id"] == "r-049"
def test_register_scorer(mock_get_request_message, mock_tracking_store):
experiment_id = "123"
name = "accuracy_scorer"
serialized_scorer = '{"name": "accuracy_scorer"}'
mock_get_request_message.return_value = RegisterScorer(
experiment_id=experiment_id, name=name, serialized_scorer=serialized_scorer
)
mock_scorer_version = ScorerVersion(
experiment_id=experiment_id,
scorer_name=name,
scorer_version=1,
serialized_scorer=serialized_scorer,
creation_time=1234567890,
scorer_id="test-scorer-id",
)
mock_tracking_store.register_scorer.return_value = mock_scorer_version
resp = _register_scorer()
mock_tracking_store.register_scorer.assert_called_once_with(
experiment_id, name, serialized_scorer
)
response_data = json.loads(resp.get_data())
assert response_data == {
"version": 1,
"scorer_id": "test-scorer-id",
"experiment_id": experiment_id,
"name": name,
"serialized_scorer": serialized_scorer,
"creation_time": 1234567890,
}
def test_register_scorer_rejects_decorator_scorer(mock_get_request_message, mock_tracking_store):
from mlflow.genai.scorers.scorer_utils import DECORATOR_SCORER_REGISTRATION_NOT_SUPPORTED_ERROR
serialized_scorer = json.dumps({"name": "my_scorer", "call_source": " return 1.0\n"})
mock_get_request_message.return_value = RegisterScorer(
experiment_id="123", name="my_scorer", serialized_scorer=serialized_scorer
)
resp = _register_scorer()
assert resp.status_code == 400
assert DECORATOR_SCORER_REGISTRATION_NOT_SUPPORTED_ERROR in resp.get_json()["message"]
mock_tracking_store.register_scorer.assert_not_called()
def test_list_scorers(mock_get_request_message, mock_tracking_store):
experiment_id = "123"
mock_get_request_message.return_value = ListScorers(experiment_id=experiment_id)
# Create mock scorers
scorers = [
ScorerVersion(
experiment_id=123,
scorer_name="accuracy_scorer",
scorer_version=1,
serialized_scorer="serialized_accuracy_scorer",
creation_time=12345,
),
ScorerVersion(
experiment_id=123,
scorer_name="safety_scorer",
scorer_version=2,
serialized_scorer="serialized_safety_scorer",
creation_time=12345,
),
]
mock_tracking_store.list_scorers.return_value = scorers
resp = _list_scorers()
# Verify the tracking store was called with correct arguments
mock_tracking_store.list_scorers.assert_called_once_with(experiment_id)
# Verify the response
response_data = json.loads(resp.get_data())
assert len(response_data["scorers"]) == 2
assert response_data["scorers"][0]["scorer_name"] == "accuracy_scorer"
assert response_data["scorers"][0]["scorer_version"] == 1
assert response_data["scorers"][0]["serialized_scorer"] == "serialized_accuracy_scorer"
assert response_data["scorers"][1]["scorer_name"] == "safety_scorer"
assert response_data["scorers"][1]["scorer_version"] == 2
assert response_data["scorers"][1]["serialized_scorer"] == "serialized_safety_scorer"
def test_list_scorers_cross_experiment(mock_get_request_message, mock_tracking_store):
# Empty ``experiment_id`` switches to the cross-experiment branch: the
# handler walks ``search_experiments`` via ``page_token`` until exhausted,
# then makes a single ``list_scorers_across_experiments`` call with the
# collected ids. Mocks below force a 3-page walk so the loop is exercised
# end-to-end (not just the first page).
mock_get_request_message.return_value = ListScorers()
def _make_page(items, token):
return PagedList(
[
Experiment(
experiment_id=str(i),
name=f"e-{i}",
artifact_location="",
lifecycle_stage="active",
)
for i in items
],
token,
)
mock_tracking_store.search_experiments.side_effect = [
_make_page([1, 2], "tok-1"),
_make_page([3], "tok-2"),
_make_page([], None), # terminal page with no token
]
mock_tracking_store.list_scorers_across_experiments.return_value = [
ScorerVersion(
experiment_id=1,
scorer_name="alpha",
scorer_version=1,
serialized_scorer="s",
creation_time=0,
),
]
_list_scorers()
# ``search_experiments`` is called 3 times: initial + two follow-ups using
# the prior page's token. ``list_scorers`` (single-experiment) must NOT be
# called on this branch.
assert mock_tracking_store.search_experiments.call_count == 3
page_tokens = [
c.kwargs.get("page_token") for c in mock_tracking_store.search_experiments.call_args_list
]
assert page_tokens == [None, "tok-1", "tok-2"]
mock_tracking_store.list_scorers.assert_not_called()
mock_tracking_store.list_scorers_across_experiments.assert_called_once()
# Collected experiment ids: union of every page's items, in order.
call_args = mock_tracking_store.list_scorers_across_experiments.call_args
assert call_args.args[0] == ["1", "2", "3"]
def test_list_scorer_versions(mock_get_request_message, mock_tracking_store):
experiment_id = "123"
name = "accuracy_scorer"
mock_get_request_message.return_value = ListScorerVersions(
experiment_id=experiment_id, name=name
)
# Create mock scorers with multiple versions
scorers = [
ScorerVersion(
experiment_id=123,
scorer_name="accuracy_scorer",
scorer_version=1,
serialized_scorer="serialized_accuracy_scorer_v1",
creation_time=12345,
),
ScorerVersion(
experiment_id=123,
scorer_name="accuracy_scorer",
scorer_version=2,
serialized_scorer="serialized_accuracy_scorer_v2",
creation_time=12345,
),
]
mock_tracking_store.list_scorer_versions.return_value = scorers
resp = _list_scorer_versions()
# Verify the tracking store was called with correct arguments
mock_tracking_store.list_scorer_versions.assert_called_once_with(experiment_id, name)
# Verify the response
response_data = json.loads(resp.get_data())
assert len(response_data["scorers"]) == 2
assert response_data["scorers"][0]["scorer_version"] == 1
assert response_data["scorers"][0]["serialized_scorer"] == "serialized_accuracy_scorer_v1"
assert response_data["scorers"][1]["scorer_version"] == 2
assert response_data["scorers"][1]["serialized_scorer"] == "serialized_accuracy_scorer_v2"
def test_get_scorer_with_version(mock_get_request_message, mock_tracking_store):
experiment_id = "123"
name = "accuracy_scorer"
version = 2
mock_get_request_message.return_value = GetScorer(
experiment_id=experiment_id, name=name, version=version
)
# Mock the return value as a ScorerVersion entity
mock_scorer_version = ScorerVersion(
experiment_id=123,
scorer_name="accuracy_scorer",
scorer_version=2,
serialized_scorer="serialized_accuracy_scorer_v2",
creation_time=1640995200000,
)
mock_tracking_store.get_scorer.return_value = mock_scorer_version
resp = _get_scorer()
# Verify the tracking store was called with correct arguments (positional)
mock_tracking_store.get_scorer.assert_called_once_with(experiment_id, name, version)
# Verify the response
response_data = json.loads(resp.get_data())
assert response_data["scorer"]["experiment_id"] == 123
assert response_data["scorer"]["scorer_name"] == "accuracy_scorer"
assert response_data["scorer"]["scorer_version"] == 2
assert response_data["scorer"]["serialized_scorer"] == "serialized_accuracy_scorer_v2"
assert response_data["scorer"]["creation_time"] == 1640995200000
def test_get_scorer_without_version(mock_get_request_message, mock_tracking_store):
experiment_id = "123"
name = "accuracy_scorer"
mock_get_request_message.return_value = GetScorer(experiment_id=experiment_id, name=name)
# Mock the return value as a ScorerVersion entity
mock_scorer_version = ScorerVersion(
experiment_id=123,
scorer_name="accuracy_scorer",
scorer_version=3,
serialized_scorer="serialized_accuracy_scorer_latest",
creation_time=1640995200000,
)
mock_tracking_store.get_scorer.return_value = mock_scorer_version
resp = _get_scorer()
# Verify the tracking store was called with correct arguments (positional, version=None)
mock_tracking_store.get_scorer.assert_called_once_with(experiment_id, name, None)
# Verify the response
response_data = json.loads(resp.get_data())
assert response_data["scorer"]["experiment_id"] == 123
assert response_data["scorer"]["scorer_name"] == "accuracy_scorer"
assert response_data["scorer"]["scorer_version"] == 3
assert response_data["scorer"]["serialized_scorer"] == "serialized_accuracy_scorer_latest"
assert response_data["scorer"]["creation_time"] == 1640995200000
def test_delete_scorer_with_version(mock_get_request_message, mock_tracking_store):
experiment_id = "123"
name = "accuracy_scorer"
version = 2
mock_get_request_message.return_value = DeleteScorer(
experiment_id=experiment_id, name=name, version=version
)
resp = _delete_scorer()
# Verify the tracking store was called with correct arguments (positional)
mock_tracking_store.delete_scorer.assert_called_once_with(experiment_id, name, version)
# Verify the response (should be empty for delete operations)
response_data = json.loads(resp.get_data())
assert response_data == {}
def test_delete_scorer_without_version(mock_get_request_message, mock_tracking_store):
experiment_id = "123"
name = "accuracy_scorer"
mock_get_request_message.return_value = DeleteScorer(experiment_id=experiment_id, name=name)
resp = _delete_scorer()
# Verify the tracking store was called with correct arguments (positional, version=None)
mock_tracking_store.delete_scorer.assert_called_once_with(experiment_id, name, None)
# Verify the response (should be empty for delete operations)
response_data = json.loads(resp.get_data())
assert response_data == {}
def test_get_online_scoring_configs_batch(mock_tracking_store):
mock_configs = [
OnlineScoringConfig(
online_scoring_config_id="cfg-1",
scorer_id="scorer-1",
sample_rate=0.5,
filter_string="status = 'OK'",
experiment_id="exp1",
),
OnlineScoringConfig(
online_scoring_config_id="cfg-2",
scorer_id="scorer-2",
sample_rate=0.8,
experiment_id="exp1",
),
]
mock_tracking_store.get_online_scoring_configs.return_value = mock_configs
with app.test_client() as c:
resp = c.get(
"/ajax-api/3.0/mlflow/scorers/online-configs",
query_string=[("scorer_ids", "scorer-1"), ("scorer_ids", "scorer-2")],
)
assert resp.status_code == 200
data = resp.get_json()
assert "configs" in data
assert isinstance(data["configs"], list)
assert len(data["configs"]) == 2
configs_by_id = {c["scorer_id"]: c for c in data["configs"]}
assert configs_by_id["scorer-1"]["sample_rate"] == 0.5
assert configs_by_id["scorer-1"]["filter_string"] == "status = 'OK'"
assert configs_by_id["scorer-2"]["sample_rate"] == 0.8
assert configs_by_id["scorer-2"].get("filter_string") is None
mock_tracking_store.get_online_scoring_configs.assert_called_once_with(["scorer-1", "scorer-2"])
def test_get_online_scoring_configs_missing_param(mock_tracking_store):
with app.test_client() as c:
resp = c.get(
"/ajax-api/3.0/mlflow/scorers/online-configs",
)
assert resp.status_code == 400
data = resp.get_json()
assert "scorer_ids" in data["message"]
def test_calculate_trace_filter_correlation(mock_get_request_message, mock_tracking_store):
experiment_ids = ["123", "456"]
filter_string1 = "span.type = 'LLM'"
filter_string2 = "feedback.quality > 0.8"
base_filter = "request_time > 1000"
mock_request = CalculateTraceFilterCorrelation(
experiment_ids=experiment_ids,
filter_string1=filter_string1,
filter_string2=filter_string2,
base_filter=base_filter,
)
mock_get_request_message.return_value = mock_request
mock_result = TraceFilterCorrelationResult(
npmi=0.456,
npmi_smoothed=0.445,
filter1_count=100,
filter2_count=80,
joint_count=50,
total_count=200,
)
mock_tracking_store.calculate_trace_filter_correlation.return_value = mock_result
resp = _calculate_trace_filter_correlation()
mock_tracking_store.calculate_trace_filter_correlation.assert_called_once_with(
experiment_ids=experiment_ids,
filter_string1=filter_string1,
filter_string2=filter_string2,
base_filter=base_filter,
)
response_data = json.loads(resp.get_data())
assert response_data["npmi"] == 0.456
assert response_data["npmi_smoothed"] == 0.445
assert response_data["filter1_count"] == 100
assert response_data["filter2_count"] == 80
assert response_data["joint_count"] == 50
assert response_data["total_count"] == 200
def test_calculate_trace_filter_correlation_without_base_filter(
mock_get_request_message, mock_tracking_store
):
experiment_ids = ["123"]
filter_string1 = "span.type = 'LLM'"
filter_string2 = "feedback.quality > 0.8"
mock_request = CalculateTraceFilterCorrelation(
experiment_ids=experiment_ids,
filter_string1=filter_string1,
filter_string2=filter_string2,
)
mock_get_request_message.return_value = mock_request
mock_result = TraceFilterCorrelationResult(
npmi=0.789,
npmi_smoothed=0.775,
filter1_count=50,
filter2_count=40,
joint_count=30,
total_count=100,
)
mock_tracking_store.calculate_trace_filter_correlation.return_value = mock_result
resp = _calculate_trace_filter_correlation()
mock_tracking_store.calculate_trace_filter_correlation.assert_called_once_with(
experiment_ids=experiment_ids,
filter_string1=filter_string1,
filter_string2=filter_string2,
base_filter=None,
)
response_data = json.loads(resp.get_data())
assert response_data["npmi"] == 0.789
assert response_data["npmi_smoothed"] == 0.775
assert response_data["filter1_count"] == 50
assert response_data["filter2_count"] == 40
assert response_data["joint_count"] == 30
assert response_data["total_count"] == 100
def test_calculate_trace_filter_correlation_with_nan_npmi(
mock_get_request_message, mock_tracking_store
):
experiment_ids = ["123"]
filter_string1 = "span.type = 'LLM'"
filter_string2 = "feedback.quality > 0.8"
mock_request = CalculateTraceFilterCorrelation(
experiment_ids=experiment_ids,
filter_string1=filter_string1,
filter_string2=filter_string2,
)
mock_get_request_message.return_value = mock_request
mock_result = TraceFilterCorrelationResult(
npmi=float("nan"),
npmi_smoothed=None,
filter1_count=0,
filter2_count=0,
joint_count=0,
total_count=100,
)
mock_tracking_store.calculate_trace_filter_correlation.return_value = mock_result
resp = _calculate_trace_filter_correlation()
mock_tracking_store.calculate_trace_filter_correlation.assert_called_once_with(
experiment_ids=experiment_ids,
filter_string1=filter_string1,
filter_string2=filter_string2,
base_filter=None,
)
response_data = json.loads(resp.get_data())
assert "npmi" not in response_data
assert "npmi_smoothed" not in response_data
assert response_data["filter1_count"] == 0
assert response_data["filter2_count"] == 0
assert response_data["joint_count"] == 0
assert response_data["total_count"] == 100
def test_databricks_tracking_store_registration():
registry = TrackingStoreRegistryWrapper()
# Test that the correct store type is returned for databricks scheme
store = registry.get_store("databricks", artifact_uri=None)
assert isinstance(store, DatabricksTracingRestStore)
# Verify that the store was created with the right get_host_creds function
# The RestStore should have a get_host_creds attribute that is a partial function
assert hasattr(store, "get_host_creds")
assert store.get_host_creds.func.__name__ == "get_databricks_host_creds"
assert store.get_host_creds.args == ("databricks",)
def test_databricks_model_registry_store_registration():
registry = ModelRegistryStoreRegistryWrapper()
# Test that the correct store type is returned for databricks
store = registry.get_store("databricks")
assert isinstance(store, ModelRegistryRestStore)
# Verify that the store was created with the right get_host_creds function
assert hasattr(store, "get_host_creds")
assert store.get_host_creds.func.__name__ == "get_databricks_host_creds"
assert store.get_host_creds.args == ("databricks",)
# Test that the correct store type is returned for databricks-uc
uc_store = registry.get_store("databricks-uc")
assert isinstance(uc_store, UcModelRegistryStore)
# Verify that the UC store was created with the right get_host_creds function
# Note: UcModelRegistryStore uses get_databricks_host_creds internally,
# not get_databricks_uc_host_creds
assert hasattr(uc_store, "get_host_creds")
assert uc_store.get_host_creds.func.__name__ == "get_databricks_host_creds"
assert uc_store.get_host_creds.args == ("databricks-uc",)
# Also verify it has tracking_uri set
assert hasattr(uc_store, "tracking_uri")
# The tracking_uri will be set based on environment/test config
# In test environment, it may be set to a test sqlite database
assert uc_store.tracking_uri is not None
def test_search_experiments_empty_page_token(mock_get_request_message, mock_tracking_store):
# Create proto without setting page_token - it defaults to empty string
search_experiments_proto = SearchExperiments()
search_experiments_proto.max_results = 10
# Verify that proto's default page_token is empty string
assert search_experiments_proto.page_token == ""
mock_get_request_message.return_value = search_experiments_proto
mock_tracking_store.search_experiments.return_value = PagedList([], None)
_search_experiments()
# Verify that search_experiments was called with page_token=None (not empty string)
mock_tracking_store.search_experiments.assert_called_once()
call_kwargs = mock_tracking_store.search_experiments.call_args.kwargs
assert call_kwargs.get("page_token") is None
assert call_kwargs.get("max_results") == 10
def test_search_registered_models_empty_page_token(
mock_get_request_message, mock_model_registry_store
):
# Create proto without setting page_token - it defaults to empty string
search_registered_models_proto = SearchRegisteredModels()
search_registered_models_proto.max_results = 10
# Verify that proto's default page_token is empty string
assert search_registered_models_proto.page_token == ""
mock_get_request_message.return_value = search_registered_models_proto
mock_model_registry_store.search_registered_models.return_value = PagedList([], None)
_search_registered_models()
# Verify that search_registered_models was called with page_token=None (not empty string)
mock_model_registry_store.search_registered_models.assert_called_once()
call_kwargs = mock_model_registry_store.search_registered_models.call_args.kwargs
assert call_kwargs.get("page_token") is None
assert call_kwargs.get("max_results") == 10
def test_search_model_versions_empty_page_token(
mock_get_request_message, mock_model_registry_store
):
# Create proto without setting page_token - it defaults to empty string
search_model_versions_proto = SearchModelVersions()
search_model_versions_proto.max_results = 10
# Verify that proto's default page_token is empty string
assert search_model_versions_proto.page_token == ""
mock_get_request_message.return_value = search_model_versions_proto
mock_model_registry_store.search_model_versions.return_value = PagedList([], None)
_search_model_versions()
# Verify that search_model_versions was called with page_token=None (not empty string)
mock_model_registry_store.search_model_versions.assert_called_once()
call_kwargs = mock_model_registry_store.search_model_versions.call_args.kwargs
assert call_kwargs.get("page_token") is None
assert call_kwargs.get("max_results") == 10
def test_search_traces_v3_empty_page_token(mock_get_request_message, mock_tracking_store):
# Create proto without setting page_token - it defaults to empty string
# SearchTracesV3 requires locations field
search_traces_proto = SearchTracesV3()
location = TraceLocation()
location.mlflow_experiment.experiment_id = "1"
search_traces_proto.locations.append(location)
search_traces_proto.max_results = 10
# Verify that proto's default page_token is empty string
assert search_traces_proto.page_token == ""
mock_get_request_message.return_value = search_traces_proto
mock_tracking_store.search_traces.return_value = ([], None)
_search_traces_v3()
# Verify that search_traces was called with page_token=None (not empty string)
mock_tracking_store.search_traces.assert_called_once()
call_kwargs = mock_tracking_store.search_traces.call_args.kwargs
assert call_kwargs.get("page_token") is None
assert call_kwargs.get("max_results") == 10
def test_deprecated_search_traces_v2_empty_page_token(
mock_get_request_message, mock_tracking_store
):
# Create proto without setting page_token - it defaults to empty string
search_traces_proto = SearchTraces()
search_traces_proto.max_results = 10
# Verify that proto's default page_token is empty string
assert search_traces_proto.page_token == ""
mock_get_request_message.return_value = search_traces_proto
mock_tracking_store.search_traces.return_value = ([], None)
_deprecated_search_traces_v2()
# Verify that search_traces was called with page_token=None (not empty string)
mock_tracking_store.search_traces.assert_called_once()
call_kwargs = mock_tracking_store.search_traces.call_args.kwargs
assert call_kwargs.get("page_token") is None
assert call_kwargs.get("max_results") == 10
def test_search_logged_models_empty_page_token(mock_get_request_message, mock_tracking_store):
# Create proto without setting page_token - it defaults to empty string
search_logged_models_proto = SearchLoggedModels()
search_logged_models_proto.max_results = 10
# Verify that proto's default page_token is empty string
assert search_logged_models_proto.page_token == ""
mock_get_request_message.return_value = search_logged_models_proto
mock_tracking_store.search_logged_models.return_value = PagedList([], None)
_search_logged_models()
# Verify that search_logged_models was called with page_token=None (not empty string)
mock_tracking_store.search_logged_models.assert_called_once()
call_kwargs = mock_tracking_store.search_logged_models.call_args.kwargs
assert call_kwargs.get("page_token") is None
assert call_kwargs.get("max_results") == 10
def test_list_webhooks_empty_page_token(mock_get_request_message, mock_model_registry_store):
# Create proto without setting page_token - it defaults to empty string
list_webhooks_proto = ListWebhooks()
list_webhooks_proto.max_results = 10
# Verify that proto's default page_token is empty string
assert list_webhooks_proto.page_token == ""
mock_get_request_message.return_value = list_webhooks_proto
mock_model_registry_store.list_webhooks.return_value = PagedList([], None)
_list_webhooks()
# Verify that list_webhooks was called with page_token=None (not empty string)
mock_model_registry_store.list_webhooks.assert_called_once()
call_kwargs = mock_model_registry_store.list_webhooks.call_args.kwargs
assert call_kwargs.get("page_token") is None
assert call_kwargs.get("max_results") == 10
def test_batch_get_traces_handler(mock_get_request_message, mock_tracking_store):
trace_id_1 = "test-trace-123"
trace_id_2 = "test-trace-456"
get_traces_proto = BatchGetTraces(trace_ids=[trace_id_1, trace_id_2])
mock_get_request_message.return_value = get_traces_proto
otel_span = OTelReadableSpan(
name="test",
context=build_otel_context(123, 234),
parent=None,
start_time=100,
end_time=200,
attributes={
"mlflow.spanInputs": json.dumps("inputs"),
"mlflow.spanOutputs": json.dumps("outputs"),
"mlflow.spanType": json.dumps("span_type"),
},
)
mock_span = Span(otel_span)
# Create mock traces to return
mock_trace_1 = Trace(
info=TraceInfo(
trace_id=trace_id_1,
trace_location=EntityTraceLocation.from_experiment_id("1"),
request_time=1234567890,
execution_duration=5000,
state=TraceState.OK,
),
data=TraceData(spans=[mock_span]),
)
mock_trace_2 = Trace(
info=TraceInfo(
trace_id=trace_id_2,
trace_location=EntityTraceLocation.from_experiment_id("1"),
request_time=1234567890,
execution_duration=3000,
state=TraceState.OK,
),
data=TraceData(spans=[mock_span]),
)
mock_tracking_store.batch_get_traces.return_value = [mock_trace_1, mock_trace_2]
# Call the handler
response = _batch_get_traces()
# Verify the store was called with the correct trace IDs
mock_tracking_store.batch_get_traces.assert_called_once_with([trace_id_1, trace_id_2], None)
# Verify response was created
assert response is not None
assert response.status_code == 200
traces = json.loads(response.get_data())["traces"]
assert len(traces) == 2
assert len(traces[0]["spans"]) == 1
assert len(traces[1]["spans"]) == 1
def test_batch_get_traces_handler_empty_list(mock_get_request_message, mock_tracking_store):
get_traces_proto = BatchGetTraces()
mock_get_request_message.return_value = get_traces_proto
mock_tracking_store.batch_get_traces.return_value = []
response = _batch_get_traces()
mock_tracking_store.batch_get_traces.assert_called_once_with([], None)
# Verify response was created
assert response is not None
assert response.status_code == 200
def test_batch_get_trace_infos_handler(mock_get_request_message, mock_tracking_store):
trace_id_1 = "test-trace-123"
trace_id_2 = "test-trace-456"
mock_get_request_message.return_value = BatchGetTraceInfos(trace_ids=[trace_id_1, trace_id_2])
mock_trace_info_1 = TraceInfo(
trace_id=trace_id_1,
trace_location=EntityTraceLocation.from_experiment_id("1"),
request_time=1234567890,
execution_duration=5000,
state=TraceState.OK,
)
mock_trace_info_2 = TraceInfo(
trace_id=trace_id_2,
trace_location=EntityTraceLocation.from_experiment_id("1"),
request_time=1234567890,
execution_duration=3000,
state=TraceState.OK,
)
mock_tracking_store.batch_get_trace_infos.return_value = [
mock_trace_info_1,
mock_trace_info_2,
]
response = _batch_get_trace_infos()
mock_tracking_store.batch_get_trace_infos.assert_called_once_with([trace_id_1, trace_id_2])
assert response is not None
assert response.status_code == 200
trace_infos = json.loads(response.get_data())["trace_infos"]
assert len(trace_infos) == 2
assert trace_infos[0]["trace_id"] == trace_id_1
assert trace_infos[1]["trace_id"] == trace_id_2
def test_get_trace_handler(mock_get_request_message, mock_tracking_store):
trace_id = "test-trace-123"
get_trace_proto = GetTrace(trace_id=trace_id, allow_partial=True)
mock_get_request_message.return_value = get_trace_proto
otel_span = OTelReadableSpan(
name="test",
context=build_otel_context(123, 234),
parent=None,
start_time=100,
end_time=200,
attributes={
"mlflow.spanInputs": json.dumps("inputs"),
"mlflow.spanOutputs": json.dumps("outputs"),
"mlflow.spanType": json.dumps("span_type"),
},
)
mock_span = Span(otel_span)
mock_trace = Trace(
info=TraceInfo(
trace_id=trace_id,
trace_location=EntityTraceLocation.from_experiment_id("1"),
request_time=1234567890,
execution_duration=5000,
state=TraceState.OK,
),
data=TraceData(spans=[mock_span]),
)
mock_tracking_store.get_trace.return_value = mock_trace
response = _get_trace()
mock_tracking_store.get_trace.assert_called_once_with(trace_id, allow_partial=True)
assert response is not None
assert response.status_code == 200
response_data = json.loads(response.get_data())
assert "trace" in response_data
trace = response_data["trace"]
assert trace["trace_info"]["trace_id"] == trace_id
assert len(trace["spans"]) == 1
def test_get_trace_handler_with_allow_partial_false(mock_get_request_message, mock_tracking_store):
trace_id = "test-trace-456"
get_trace_proto = GetTrace(trace_id=trace_id, allow_partial=False)
mock_get_request_message.return_value = get_trace_proto
otel_span = OTelReadableSpan(
name="test",
context=build_otel_context(123, 234),
parent=None,
start_time=100,
end_time=200,
attributes={},
)
mock_span = Span(otel_span)
mock_trace = Trace(
info=TraceInfo(
trace_id=trace_id,
trace_location=EntityTraceLocation.from_experiment_id("1"),
request_time=1234567890,
execution_duration=5000,
state=TraceState.OK,
),
data=TraceData(spans=[mock_span]),
)
mock_tracking_store.get_trace.return_value = mock_trace
response = _get_trace()
mock_tracking_store.get_trace.assert_called_once_with(trace_id, allow_partial=False)
assert response is not None
assert response.status_code == 200
response_data = json.loads(response.get_data())
assert "trace" in response_data
def test_get_trace_handler_not_found(mock_get_request_message, mock_tracking_store):
trace_id = "non-existent-trace"
get_trace_proto = GetTrace(trace_id=trace_id)
mock_get_request_message.return_value = get_trace_proto
mock_tracking_store.get_trace.side_effect = MlflowException(
f"Trace with ID {trace_id} is not found.",
error_code=RESOURCE_DOES_NOT_EXIST,
)
response = _get_trace()
mock_tracking_store.get_trace.assert_called_once_with(trace_id, allow_partial=False)
assert response is not None
assert response.status_code == 404
response_data = json.loads(response.get_data())
assert "error_code" in response_data
assert response_data["error_code"] == "RESOURCE_DOES_NOT_EXIST"
def test_get_trace_artifact_handler(mock_tracking_store):
trace_id = "test-trace-artifact-123"
otel_span = OTelReadableSpan(
name="test_span",
context=build_otel_context(123, 234),
parent=None,
start_time=100,
end_time=200,
attributes={
"mlflow.spanInputs": json.dumps({"input": "test_input"}),
"mlflow.spanOutputs": json.dumps({"output": "test_output"}),
},
)
mock_span = Span(otel_span)
mock_trace = Trace(
info=TraceInfo(
trace_id=trace_id,
trace_location=EntityTraceLocation.from_experiment_id("1"),
request_time=1234567890,
execution_duration=5000,
state=TraceState.OK,
),
data=TraceData(spans=[mock_span]),
)
mock_tracking_store.get_trace.return_value = mock_trace
mock_tracking_store.batch_get_traces.return_value = [mock_trace]
with app.test_request_context(method="GET", query_string={"request_id": trace_id}):
response = get_trace_artifact_handler()
# Verify the store was called correctly
mock_tracking_store.get_trace.assert_called_once_with(trace_id, allow_partial=True)
# Verify response headers and status
assert response is not None
assert response.status_code == 200
assert response.headers["Content-Disposition"] == "attachment; filename=traces.json"
def test_get_trace_artifact_handler_missing_request_id(mock_tracking_store):
with app.test_request_context(method="GET"):
response = get_trace_artifact_handler()
assert response.status_code == 400
response_data = json.loads(response.get_data())
assert "error_code" in response_data
assert response_data["error_code"] == "BAD_REQUEST"
assert 'must include the "request_id" query parameter' in response_data["message"]
def test_get_trace_artifact_handler_trace_not_found(mock_tracking_store):
trace_id = "non-existent-trace"
mock_tracking_store.get_trace.side_effect = MlflowException(
f"Trace with ID {trace_id} is not found.",
error_code=RESOURCE_DOES_NOT_EXIST,
)
with app.test_request_context(method="GET", query_string={"request_id": trace_id}):
response = get_trace_artifact_handler()
mock_tracking_store.get_trace.assert_called_once_with(trace_id, allow_partial=True)
assert response.status_code == 404
response_data = json.loads(response.get_data())
assert "error_code" in response_data
assert response_data["error_code"] == "RESOURCE_DOES_NOT_EXIST"
assert f"Trace with ID {trace_id} is not found" in response_data["message"]
def test_get_trace_artifact_handler_fallback_to_batch_get_traces(mock_tracking_store):
trace_id = "test-trace-batch-123"
otel_span = OTelReadableSpan(
name="test_span_batch",
context=build_otel_context(456, 789),
parent=None,
start_time=100,
end_time=200,
attributes={
"mlflow.spanInputs": json.dumps({"input": "batch_input"}),
"mlflow.spanOutputs": json.dumps({"output": "batch_output"}),
},
)
mock_span = Span(otel_span)
mock_trace = Trace(
info=TraceInfo(
trace_id=trace_id,
trace_location=EntityTraceLocation.from_experiment_id("2"),
request_time=1234567890,
execution_duration=3000,
state=TraceState.OK,
),
data=TraceData(spans=[mock_span]),
)
# Simulate get_trace not being implemented
mock_tracking_store.get_trace.side_effect = MlflowNotImplementedException(
"get_trace is not implemented"
)
mock_tracking_store.batch_get_traces.return_value = [mock_trace]
with app.test_request_context(method="GET", query_string={"request_id": trace_id}):
response = get_trace_artifact_handler()
# Verify both methods were called
mock_tracking_store.get_trace.assert_called_once_with(trace_id, allow_partial=True)
mock_tracking_store.batch_get_traces.assert_called_once_with([trace_id], None)
# Verify successful response
assert response is not None
assert response.status_code == 200
assert response.headers["Content-Disposition"] == "attachment; filename=traces.json"
def test_get_trace_artifact_handler_batch_get_traces_not_found(mock_tracking_store):
trace_id = "non-existent-batch-trace"
# Simulate get_trace not being implemented
mock_tracking_store.get_trace.side_effect = MlflowNotImplementedException(
"get_trace is not implemented"
)
# batch_get_traces returns empty list (trace not found)
mock_tracking_store.batch_get_traces.return_value = []
with app.test_request_context(method="GET", query_string={"request_id": trace_id}):
response = get_trace_artifact_handler()
# Verify both methods were called
mock_tracking_store.get_trace.assert_called_once_with(trace_id, allow_partial=True)
mock_tracking_store.batch_get_traces.assert_called_once_with([trace_id], None)
# Verify 404 response
assert response.status_code == 404
response_data = json.loads(response.get_data())
assert "error_code" in response_data
assert response_data["error_code"] == "RESOURCE_DOES_NOT_EXIST"
assert f"Trace with id={trace_id} not found" in response_data["message"]
def test_get_trace_artifact_handler_fallback_to_artifact_repo(mock_tracking_store):
trace_id = "test-trace-artifact-repo-123"
trace_info = TraceInfo(
trace_id=trace_id,
trace_location=EntityTraceLocation.from_experiment_id("3"),
request_time=1234567890,
execution_duration=4000,
state=TraceState.OK,
)
trace_data = {
"spans": [
{
"name": "artifact_span",
"context": {"trace_id": trace_id, "span_id": "123"},
"parent_id": None,
"start_time": 100,
"end_time": 200,
"status_code": "OK",
"status_message": "",
"attributes": {},
"events": [],
}
]
}
# Simulate batch_get_traces not being implemented
mock_tracking_store.get_trace.side_effect = MlflowNotImplementedException(
"get_trace is not implemented"
)
mock_tracking_store.batch_get_traces.side_effect = MlflowNotImplementedException(
"batch_get_traces is not implemented"
)
mock_tracking_store.get_trace_info.return_value = trace_info
# Mock the artifact repo
mock_artifact_repo = mock.MagicMock()
mock_artifact_repo.download_trace_data.return_value = trace_data
with mock.patch(
"mlflow.server.handlers._get_trace_artifact_repo", return_value=mock_artifact_repo
):
with app.test_request_context(method="GET", query_string={"request_id": trace_id}):
response = get_trace_artifact_handler()
# Verify the fallback path was taken
mock_tracking_store.get_trace.assert_called_once_with(trace_id, allow_partial=True)
mock_tracking_store.batch_get_traces.assert_called_once_with([trace_id], None)
mock_tracking_store.get_trace_info.assert_called_once_with(trace_id)
mock_artifact_repo.download_trace_data.assert_called_once()
# Verify successful response
assert response is not None
assert response.status_code == 200
assert response.headers["Content-Disposition"] == "attachment; filename=traces.json"
def test_get_trace_artifact_handler_with_attachment_path(mock_tracking_store):
trace_id = "tr-test-attachment-123"
attachment_id = "a1b2c3d4-e5f6-4890-abcd-ef1234567890"
trace_info = TraceInfo(
trace_id=trace_id,
trace_location=EntityTraceLocation.from_experiment_id("3"),
request_time=1234567890,
execution_duration=4000,
state=TraceState.OK,
)
mock_tracking_store.get_trace_info.return_value = trace_info
mock_artifact_repo = mock.MagicMock()
mock_artifact_repo.download_trace_attachment.return_value = b"\x89PNG fake image"
with mock.patch(
"mlflow.server.handlers._get_trace_artifact_repo", return_value=mock_artifact_repo
):
query = {"request_id": trace_id, "path": attachment_id}
with app.test_request_context(method="GET", query_string=query):
response = get_trace_artifact_handler()
mock_tracking_store.get_trace_info.assert_called_once_with(trace_id)
mock_artifact_repo.download_trace_attachment.assert_called_once_with(attachment_id)
assert response.status_code == 200
assert response.headers["Content-Type"] == "application/octet-stream"
assert response.headers["Content-Disposition"] == f"attachment; filename={attachment_id}"
assert response.headers["X-Content-Type-Options"] == "nosniff"
def test_get_trace_artifact_handler_falls_back_to_archive_repo(mock_tracking_store):
trace_id = "tr-test-archive-fallback"
trace_info = TraceInfo(
trace_id=trace_id,
trace_location=EntityTraceLocation.from_experiment_id("3"),
request_time=1234567890,
execution_duration=4000,
state=TraceState.OK,
tags={
MLFLOW_ARTIFACT_LOCATION: "dbfs:/trace-artifacts",
TraceTagKey.SPANS_LOCATION: SpansLocation.ARCHIVE_REPO.value,
TraceTagKey.ARCHIVE_LOCATION: "dbfs:/trace-archive",
},
)
mock_tracking_store.get_trace.side_effect = MlflowTracingException("archive-backed trace")
mock_tracking_store.get_trace_info.return_value = trace_info
mock_archive_repo = mock.MagicMock()
mock_archive_repo.download_archived_trace_data.return_value = TraceData(spans=[])
with mock.patch(
"mlflow.server.handlers._get_trace_archive_repo", return_value=mock_archive_repo
):
with app.test_request_context(method="GET", query_string={"request_id": trace_id}):
response = get_trace_artifact_handler()
mock_tracking_store.get_trace.assert_called_once_with(trace_id, allow_partial=True)
mock_tracking_store.get_trace_info.assert_called_once_with(trace_id)
mock_archive_repo.download_archived_trace_data.assert_called_once()
assert response.status_code == 200
assert response.headers["Content-Disposition"] == "attachment; filename=traces.json"
def test_get_trace_artifact_handler_attachment_missing_request_id():
query = {"path": "a1b2c3d4-e5f6-4890-abcd-ef1234567890"}
with app.test_request_context(method="GET", query_string=query):
response = get_trace_artifact_handler()
assert response.status_code == 400
def test_get_trace_artifact_handler_attachment_trace_not_found(mock_tracking_store):
mock_tracking_store.get_trace_info.return_value = None
query = {"request_id": "tr-nonexistent", "path": "a1b2c3d4-e5f6-4890-abcd-ef1234567890"}
with app.test_request_context(method="GET", query_string=query):
response = get_trace_artifact_handler()
assert response.status_code == 404
def test_delete_trace_tag_v2_handler(mock_get_request_message, mock_tracking_store):
"""Test v2 delete_trace_tag handler with request_id parameter.
Verifies that when the Flask route uses request_id path parameter,
the _delete_trace_tag handler is called and invokes store.delete_trace_tag().
"""
request_id = "tr-123v2"
tag_key = "tk"
# Create the request message
request_msg = DeleteTraceTag(key=tag_key)
mock_get_request_message.return_value = request_msg
# Call the v2 handler with request_id parameter
response = _delete_trace_tag(request_id=request_id)
# Verify the store method was called with correct parameters
mock_tracking_store.delete_trace_tag.assert_called_once_with(request_id, tag_key)
assert response is not None
assert response.status_code == 200
def test_delete_trace_tag_v3_handler(mock_get_request_message, mock_tracking_store):
"""Test v3 delete_trace_tag handler with trace_id parameter.
Verifies that when the Flask route uses trace_id path parameter,
the _delete_trace_tag_v3 handler is called and invokes store.delete_trace_tag().
This is similar to v2 but uses the v3 proto message and route parameter naming.
"""
trace_id = "tr-v3-456"
tag_key = "tk"
# Create the request message with V3
request_msg = DeleteTraceTagV3(key=tag_key)
mock_get_request_message.return_value = request_msg
# Call the v3 handler with trace_id parameter
response = _delete_trace_tag_v3(trace_id=trace_id)
# Verify the store method was called with correct parameters
# Both v2 and v3 call the same store method
mock_tracking_store.delete_trace_tag.assert_called_once_with(trace_id, tag_key)
assert response is not None
assert response.status_code == 200
@pytest.mark.parametrize(
"tag_key",
[
TraceTagKey.SPANS_LOCATION,
TraceTagKey.ARCHIVE_LOCATION,
],
)
def test_delete_trace_tag_v2_handler_rejects_archival_immutable_tags(
mock_get_request_message, mock_tracking_store, tag_key
):
request_msg = DeleteTraceTag(key=tag_key)
mock_get_request_message.return_value = request_msg
response = _delete_trace_tag(request_id="tr-123v2")
assert response.status_code == 400
response_data = json.loads(response.get_data())
assert response_data["error_code"] == ErrorCode.Name(INVALID_PARAMETER_VALUE)
assert response_data["message"] == (
f"Tag '{tag_key}' is immutable and cannot be deleted on a trace."
)
mock_tracking_store.delete_trace_tag.assert_not_called()
@pytest.mark.parametrize(
"tag_key",
[
TraceTagKey.SPANS_LOCATION,
TraceTagKey.ARCHIVE_LOCATION,
],
)
def test_delete_trace_tag_v3_handler_rejects_archival_immutable_tags(
mock_get_request_message, mock_tracking_store, tag_key
):
request_msg = DeleteTraceTagV3(key=tag_key)
mock_get_request_message.return_value = request_msg
response = _delete_trace_tag_v3(trace_id="tr-v3-456")
assert response.status_code == 400
response_data = json.loads(response.get_data())
assert response_data["error_code"] == ErrorCode.Name(INVALID_PARAMETER_VALUE)
assert response_data["message"] == (
f"Tag '{tag_key}' is immutable and cannot be deleted on a trace."
)
mock_tracking_store.delete_trace_tag.assert_not_called()
def test_delete_trace_tag_v2_handler_allows_clearing_archival_failure(
mock_get_request_message, mock_tracking_store
):
request_msg = DeleteTraceTag(key=TraceTagKey.ARCHIVAL_FAILURE)
mock_get_request_message.return_value = request_msg
response = _delete_trace_tag(request_id="tr-123v2")
assert response.status_code == 200
mock_tracking_store.delete_trace_tag.assert_called_once_with(
"tr-123v2", TraceTagKey.ARCHIVAL_FAILURE
)
def test_delete_trace_tag_v3_handler_allows_clearing_archival_failure(
mock_get_request_message, mock_tracking_store
):
request_msg = DeleteTraceTagV3(key=TraceTagKey.ARCHIVAL_FAILURE)
mock_get_request_message.return_value = request_msg
response = _delete_trace_tag_v3(trace_id="tr-v3-456")
assert response.status_code == 200
mock_tracking_store.delete_trace_tag.assert_called_once_with(
"tr-v3-456", TraceTagKey.ARCHIVAL_FAILURE
)
def test_set_trace_tag_v2_handler(mock_get_request_message, mock_tracking_store):
"""Test v2 set_trace_tag handler with request_id parameter.
Verifies that when the Flask route uses request_id path parameter,
the _set_trace_tag handler is called and invokes store.set_trace_tag().
"""
trace_id = "tr-test-v2-123"
tag_key = "tk"
tag_value = "tv"
# Create the request message
request_msg = SetTraceTag(key=tag_key, value=tag_value)
mock_get_request_message.return_value = request_msg
# Call the v2 handler with request_id parameter
response = _set_trace_tag(request_id=trace_id)
# Verify the store method was called with correct parameters
mock_tracking_store.set_trace_tag.assert_called_once_with(trace_id, tag_key, tag_value)
# Verify response was created (200 status)
assert response is not None
assert response.status_code == 200
def test_set_trace_tag_v3_handler(mock_get_request_message, mock_tracking_store):
"""Test v3 set_trace_tag handler with trace_id parameter.
Verifies that when the Flask route uses trace_id path parameter,
the _set_trace_tag_v3 handler is called and invokes store.set_trace_tag().
This is similar to v2 but uses the v3 proto message and route parameter naming.
"""
trace_id = "tr-test-v3-456"
tag_key = "tk"
tag_value = "tv"
# Create the request message (v3 version)
request_msg = SetTraceTagV3(key=tag_key, value=tag_value)
mock_get_request_message.return_value = request_msg
# Call the v3 handler with trace_id parameter
response = _set_trace_tag_v3(trace_id=trace_id)
# Verify the store method was called with correct parameters
# Note: Both handlers call the same store method
mock_tracking_store.set_trace_tag.assert_called_once_with(trace_id, tag_key, tag_value)
# Verify response was created (200 status)
assert response is not None
assert response.status_code == 200
@pytest.mark.parametrize(
"tag_key",
[
TraceTagKey.SPANS_LOCATION,
TraceTagKey.ARCHIVE_LOCATION,
TraceTagKey.ARCHIVAL_FAILURE,
],
)
def test_set_trace_tag_v2_handler_rejects_archival_immutable_tags(
mock_get_request_message, mock_tracking_store, tag_key
):
request_msg = SetTraceTag(key=tag_key, value="tv")
mock_get_request_message.return_value = request_msg
response = _set_trace_tag(request_id="tr-test-v2-123")
assert response.status_code == 400
response_data = json.loads(response.get_data())
assert response_data["error_code"] == ErrorCode.Name(INVALID_PARAMETER_VALUE)
assert response_data["message"] == f"Tag '{tag_key}' is immutable and cannot be set on a trace."
mock_tracking_store.set_trace_tag.assert_not_called()
@pytest.mark.parametrize(
"tag_key",
[
TraceTagKey.SPANS_LOCATION,
TraceTagKey.ARCHIVE_LOCATION,
TraceTagKey.ARCHIVAL_FAILURE,
],
)
def test_set_trace_tag_v3_handler_rejects_archival_immutable_tags(
mock_get_request_message, mock_tracking_store, tag_key
):
request_msg = SetTraceTagV3(key=tag_key, value="tv")
mock_get_request_message.return_value = request_msg
response = _set_trace_tag_v3(trace_id="tr-test-v3-456")
assert response.status_code == 400
response_data = json.loads(response.get_data())
assert response_data["error_code"] == ErrorCode.Name(INVALID_PARAMETER_VALUE)
assert response_data["message"] == f"Tag '{tag_key}' is immutable and cannot be set on a trace."
mock_tracking_store.set_trace_tag.assert_not_called()
def test_link_prompts_to_trace_handler(mock_get_request_message, mock_tracking_store):
"""Test link_prompts_to_trace handler.
Verifies that the handler correctly parses the request and calls
store.link_prompts_to_trace() with the appropriate PromptVersion objects.
"""
trace_id = "tr-test-123"
prompt_versions_refs = [
LinkPromptsToTrace.PromptVersionRef(name="prompt1", version="1"),
LinkPromptsToTrace.PromptVersionRef(name="prompt2", version="2"),
]
# Create the request message
request_msg = LinkPromptsToTrace(trace_id=trace_id, prompt_versions=prompt_versions_refs)
mock_get_request_message.return_value = request_msg
# Call the handler
response = _link_prompts_to_trace()
# Verify the store method was called with correct parameters
# The handler should convert PromptVersionRef to PromptVersion objects
call_args = mock_tracking_store.link_prompts_to_trace.call_args
assert call_args[1]["trace_id"] == trace_id
prompt_versions = call_args[1]["prompt_versions"]
assert len(prompt_versions) == 2
assert isinstance(prompt_versions[0], PromptVersion)
assert prompt_versions[0].name == "prompt1"
assert prompt_versions[0].version == 1
assert isinstance(prompt_versions[1], PromptVersion)
assert prompt_versions[1].name == "prompt2"
assert prompt_versions[1].version == 2
# Verify response was created (200 status)
assert response is not None
assert response.status_code == 200
def test_list_providers():
with app.test_client() as c:
response = c.get("/ajax-api/3.0/mlflow/gateway/supported-providers")
assert response.status_code == 200
data = response.get_json()
assert "providers" in data
assert isinstance(data["providers"], list)
assert len(data["providers"]) > 0
assert "openai" in data["providers"]
def test_list_providers_with_allowed_filter(monkeypatch):
monkeypatch.setenv("MLFLOW_GATEWAY_ALLOWED_PROVIDERS", "openai,anthropic")
with app.test_client() as c:
response = c.get("/ajax-api/3.0/mlflow/gateway/supported-providers")
assert response.status_code == 200
data = response.get_json()
assert "openai" in data["providers"]
assert "anthropic" in data["providers"]
assert "gemini" not in data["providers"]
assert "bedrock" not in data["providers"]
def test_list_models():
with app.test_client() as c:
response = c.get("/ajax-api/3.0/mlflow/gateway/supported-models?provider=openai")
assert response.status_code == 200
data = response.get_json()
assert "models" in data
assert isinstance(data["models"], list)
assert len(data["models"]) > 0
def test_list_models_all_providers():
with app.test_client() as c:
response = c.get("/ajax-api/3.0/mlflow/gateway/supported-models")
assert response.status_code == 200
data = response.get_json()
assert "models" in data
assert isinstance(data["models"], list)
assert len(data["models"]) > 0
def test_get_provider_config():
with app.test_client() as c:
response = c.get("/ajax-api/3.0/mlflow/gateway/provider-config?provider=openai")
assert response.status_code == 200
data = response.get_json()
assert "auth_modes" in data
assert "default_mode" in data
assert data["default_mode"] == "api_key"
assert len(data["auth_modes"]) >= 1
api_key_mode = data["auth_modes"][0]
assert api_key_mode["mode"] == "api_key"
def test_get_provider_config_with_multiple_auth_modes():
with app.test_client() as c:
response = c.get("/ajax-api/3.0/mlflow/gateway/provider-config?provider=bedrock")
assert response.status_code == 200
data = response.get_json()
assert "auth_modes" in data
assert data["default_mode"] == "api_key"
assert len(data["auth_modes"]) >= 2
access_keys_mode = next(m for m in data["auth_modes"] if m["mode"] == "access_keys")
assert len(access_keys_mode["secret_fields"]) == 2
assert any(f["name"] == "aws_secret_access_key" for f in access_keys_mode["secret_fields"])
assert any(f["name"] == "aws_region_name" for f in access_keys_mode["config_fields"])
iam_role_mode = next(m for m in data["auth_modes"] if m["mode"] == "iam_role")
assert any(f["name"] == "aws_role_name" for f in iam_role_mode["config_fields"])
def test_get_provider_config_missing_provider():
with app.test_client() as c:
response = c.get("/ajax-api/3.0/mlflow/gateway/provider-config")
assert response.status_code == 400
def test_get_provider_config_with_allowed_filter(monkeypatch):
monkeypatch.setenv("MLFLOW_GATEWAY_ALLOWED_PROVIDERS", "anthropic")
with app.test_client() as c:
response = c.get("/ajax-api/3.0/mlflow/gateway/provider-config?provider=openai")
assert response.status_code == 400
data = response.get_json()
assert "not allowed" in data["message"]
response = c.get("/ajax-api/3.0/mlflow/gateway/provider-config?provider=anthropic")
assert response.status_code == 200
@pytest.mark.parametrize(
"invalid_name",
[
"invalid name", # space
"invalid/name", # slash
"invalid?name", # question mark
"invalid&name", # ampersand
"invalid#name", # hash
"invalid@name", # at sign
"invalid:name", # colon
"日本語", # unicode (Japanese)
"naïve", # unicode (accented)
],
)
def test_create_gateway_endpoint_rejects_invalid_name(mock_get_request_message, invalid_name):
from mlflow.protos.service_pb2 import CreateGatewayEndpoint
from mlflow.server.handlers import _create_gateway_endpoint
request_msg = CreateGatewayEndpoint()
request_msg.name = invalid_name
mock_get_request_message.return_value = request_msg
response = _create_gateway_endpoint()
assert response.status_code == 400
response_data = json.loads(response.get_data())
assert "Invalid endpoint name" in response_data["message"]
assert response_data["error_code"] == "INVALID_PARAMETER_VALUE"
@pytest.mark.parametrize(
"invalid_name",
[
"invalid name", # space
"invalid/name", # slash
"invalid?name", # question mark
"invalid&name", # ampersand
],
)
def test_update_gateway_endpoint_rejects_invalid_name(mock_get_request_message, invalid_name):
from mlflow.protos.service_pb2 import UpdateGatewayEndpoint
from mlflow.server.handlers import _update_gateway_endpoint
request_msg = UpdateGatewayEndpoint()
request_msg.endpoint_id = "test-endpoint-id"
request_msg.name = invalid_name
mock_get_request_message.return_value = request_msg
response = _update_gateway_endpoint()
assert response.status_code == 400
response_data = json.loads(response.get_data())
assert "Invalid endpoint name" in response_data["message"]
assert response_data["error_code"] == "INVALID_PARAMETER_VALUE"
def test_get_gateway_endpoint_by_endpoint_id(mock_get_request_message, mock_tracking_store):
request_msg = GetGatewayEndpoint()
request_msg.endpoint_id = "ep-123"
mock_get_request_message.return_value = request_msg
mock_endpoint = mock.MagicMock()
mock_endpoint.to_proto.return_value = GatewayEndpoint(endpoint_id="ep-123")
mock_tracking_store.get_gateway_endpoint.return_value = mock_endpoint
response = _get_gateway_endpoint()
mock_tracking_store.get_gateway_endpoint.assert_called_once_with(
endpoint_id="ep-123", name=None
)
assert response.status_code == 200
def test_get_gateway_endpoint_by_name(mock_get_request_message, mock_tracking_store):
request_msg = GetGatewayEndpoint()
request_msg.name = "my-endpoint"
mock_get_request_message.return_value = request_msg
mock_endpoint = mock.MagicMock()
mock_endpoint.to_proto.return_value = GatewayEndpoint(endpoint_id="ep-456", name="my-endpoint")
mock_tracking_store.get_gateway_endpoint.return_value = mock_endpoint
response = _get_gateway_endpoint()
mock_tracking_store.get_gateway_endpoint.assert_called_once_with(
endpoint_id=None, name="my-endpoint"
)
assert response.status_code == 200
def test_query_trace_metrics_handler(mock_get_request_message, mock_tracking_store):
experiment_ids = ["exp1", "exp2"]
metric_name = "latency"
# Create aggregation protos
aggregations_proto = [
MetricAggregation(aggregation_type=AggregationType.AVG).to_proto(),
MetricAggregation(
aggregation_type=AggregationType.PERCENTILE, percentile_value=95.0
).to_proto(),
]
# Create the request message
request_msg = QueryTraceMetrics(
experiment_ids=experiment_ids,
view_type=MetricViewType.TRACES.to_proto(),
metric_name=metric_name,
aggregations=aggregations_proto,
dimensions=["status", "model"],
filters=["status = 'OK'"],
time_interval_seconds=3600,
start_time_ms=1000000,
end_time_ms=2000000,
max_results=100,
page_token="token123",
)
mock_get_request_message.return_value = request_msg
# Create mock result
mock_data_points = [
MetricDataPoint(
metric_name="latency",
dimensions={"status": "OK", "model": "gpt-4"},
values={"AVG": 150.5, "P95.0": 200.0},
),
MetricDataPoint(
metric_name="latency",
dimensions={"status": "ERROR", "model": "gpt-4"},
values={"AVG": 50.0, "P95.0": 75.0},
),
]
# Create a mock result object with next_page_token attribute
mock_result = mock.MagicMock()
mock_result.__iter__ = mock.MagicMock(return_value=iter(mock_data_points))
mock_result.token = "next_token"
mock_tracking_store.query_trace_metrics.return_value = mock_result
# Call the handler
response = _query_trace_metrics()
mock_tracking_store.query_trace_metrics.assert_called_once_with(
experiment_ids=experiment_ids,
view_type=MetricViewType.TRACES,
metric_name=metric_name,
aggregations=[
MetricAggregation(aggregation_type=AggregationType.AVG),
MetricAggregation(aggregation_type=AggregationType.PERCENTILE, percentile_value=95.0),
],
dimensions=["status", "model"],
filters=["status = 'OK'"],
time_interval_seconds=3600,
start_time_ms=1000000,
end_time_ms=2000000,
max_results=100,
page_token="token123",
)
assert response is not None
assert response.status_code == 200
response_data = json.loads(response.get_data())
assert "data_points" in response_data
assert len(response_data["data_points"]) == 2
assert response_data["data_points"][0] == asdict(mock_data_points[0])
assert response_data["data_points"][1] == asdict(mock_data_points[1])
assert response_data["next_page_token"] == "next_token"
def test_query_trace_metrics_handler_empty_result(mock_get_request_message, mock_tracking_store):
request_msg = QueryTraceMetrics(
experiment_ids=["exp1"],
view_type=MetricViewType.TRACES.to_proto(),
metric_name="latency",
aggregations=[MetricAggregation(aggregation_type=AggregationType.AVG).to_proto()],
)
mock_get_request_message.return_value = request_msg
mock_result = mock.MagicMock()
mock_result.__iter__ = mock.MagicMock(return_value=iter([]))
mock_result.token = None
mock_tracking_store.query_trace_metrics.return_value = mock_result
response = _query_trace_metrics()
mock_tracking_store.query_trace_metrics.assert_called_once_with(
experiment_ids=["exp1"],
view_type=MetricViewType.TRACES,
metric_name="latency",
aggregations=[MetricAggregation(aggregation_type=AggregationType.AVG)],
dimensions=None,
filters=None,
time_interval_seconds=None,
start_time_ms=None,
end_time_ms=None,
max_results=MAX_RESULTS_QUERY_TRACE_METRICS,
page_token=None,
)
assert response is not None
assert response.status_code == 200
response_data = json.loads(response.get_data())
assert response_data == {}
def test_invoke_scorer_missing_experiment_id():
with app.test_client() as c:
response = c.post(
"/ajax-api/3.0/mlflow/scorer/invoke",
json={"serialized_scorer": "test", "trace_ids": ["trace1"]},
)
assert response.status_code == 400
data = response.get_json()
assert "experiment_id" in data["message"]
def test_invoke_scorer_missing_serialized_scorer():
with app.test_client() as c:
response = c.post(
"/ajax-api/3.0/mlflow/scorer/invoke",
json={"experiment_id": "123", "trace_ids": ["trace1"]},
)
assert response.status_code == 400
data = response.get_json()
assert "serialized_scorer" in data["message"]
def test_invoke_scorer_missing_trace_ids():
with app.test_client() as c:
response = c.post(
"/ajax-api/3.0/mlflow/scorer/invoke",
json={"experiment_id": "123", "serialized_scorer": "test"},
)
assert response.status_code == 400
data = response.get_json()
assert "Please select at least one trace to evaluate" in data["message"]
def test_invoke_scorer_submits_jobs(mock_tracking_store):
serialized_scorer = json.dumps({
"name": "test_judge",
"aggregations": [],
"description": None,
"is_session_level_scorer": False,
"mlflow_version": mlflow.__version__,
"serialization_version": 1,
"builtin_scorer_class": None,
"builtin_scorer_pydantic_data": None,
"call_source": None,
"call_signature": None,
"original_func_name": None,
"instructions_judge_pydantic_data": {
"instructions": "Test: {{ inputs }}",
"model": "openai:/gpt-4",
"feedback_value_type": {
"enum": ["Yes", "No"],
"title": "Result",
"type": "string",
},
},
})
with mock.patch("mlflow.server.jobs.submit_job") as mock_submit:
mock_job = mock.MagicMock()
mock_job.job_id = "test-job-123"
mock_submit.return_value = mock_job
with app.test_client() as c:
response = c.post(
"/ajax-api/3.0/mlflow/scorer/invoke",
json={
"experiment_id": "exp-123",
"serialized_scorer": serialized_scorer,
"trace_ids": ["trace1", "trace2"],
},
)
assert response.status_code == 200
data = response.get_json()
assert "jobs" in data
assert len(data["jobs"]) == 1
assert data["jobs"][0]["job_id"] == "test-job-123"
assert data["jobs"][0]["trace_ids"] == ["trace1", "trace2"]
mock_submit.assert_called_once()
def test_get_ui_telemetry_handler(
test_app_context, mock_telemetry_config_cache, bypass_telemetry_env_check
):
config = {
"disable_telemetry": False,
"disable_ui_telemetry": False,
"disable_ui_events": ["event1", "event2"],
"ui_rollout_percentage": 50,
}
with mock.patch(
"mlflow.server.handlers.fetch_ui_telemetry_config", return_value=config
) as mock_fetch:
response = get_ui_telemetry_handler()
assert response is not None
assert response.status_code == 200
response_data = json.loads(response.get_data())
assert response_data["disable_ui_telemetry"] is False
assert response_data["disable_ui_events"] == ["event1", "event2"]
# rollout percent gets converted to a float as that is the proto definition
assert response_data["ui_rollout_percentage"] == 50.0
assert "config" in mock_telemetry_config_cache
assert mock_fetch.call_count == 1
mock_fetch.reset_mock()
# subsequent call should hit cache
response = get_ui_telemetry_handler()
mock_fetch.assert_not_called()
assert response_data["disable_ui_telemetry"] is False
assert response_data["disable_ui_events"] == ["event1", "event2"]
assert response_data["ui_rollout_percentage"] == 50.0
def test_get_ui_telemetry_handler_disabled_by_config(
test_app_context, mock_telemetry_config_cache, bypass_telemetry_env_check
):
config = {
"disable_telemetry": True,
"disable_ui_telemetry": False,
"disable_ui_events": [],
"ui_rollout_percentage": 0,
}
with mock.patch(
"mlflow.server.handlers.fetch_ui_telemetry_config", return_value=config
) as mock_fetch:
response = get_ui_telemetry_handler()
assert response is not None
assert response.status_code == 200
response_data = json.loads(response.get_data())
# if disable_telemetry is True, the server should always report
# that UI telemetry is disabled regardless of disable_ui_telemetry
assert response_data["disable_ui_telemetry"] is True
assert response_data["ui_rollout_percentage"] == 0.0
assert response_data["disable_ui_events"] == []
assert mock_fetch.call_count == 1
def test_get_ui_telemetry_handler_disabled_by_env(
test_app_context, mock_telemetry_config_cache, bypass_telemetry_env_check, monkeypatch
):
monkeypatch.setenv("DO_NOT_TRACK", "true")
with mock.patch("mlflow.server.handlers.fetch_ui_telemetry_config") as mock_fetch:
response = get_ui_telemetry_handler()
assert response is not None
assert response.status_code == 200
response_data = json.loads(response.get_data())
# if telemetry is disabled by env var, the server should always report
# that UI telemetry is disabled, and no config fetch should happen
mock_fetch.assert_not_called()
assert response_data["disable_ui_telemetry"] is True
assert response_data["ui_rollout_percentage"] == 0.0
assert response_data["disable_ui_events"] == []
def test_get_ui_telemetry_handler_fallback_values(
test_app_context, mock_telemetry_config_cache, bypass_telemetry_env_check
):
config_without_ui_fields = {
"disable_telemetry": False,
"rollout_percentage": 100,
}
# test fallback values if we forget to define UI config fields
with mock.patch("requests.get", return_value=config_without_ui_fields):
response = get_ui_telemetry_handler()
assert response is not None
assert response.status_code == 200
response_data = json.loads(response.get_data())
assert response_data["disable_ui_telemetry"] is True
assert response_data["ui_rollout_percentage"] == 0
assert response_data["disable_ui_events"] == []
# test fallback values if we fail to fetch the config
with mock.patch("requests.get", return_value=mock.Mock(status_code=404)):
response = get_ui_telemetry_handler()
assert response.status_code == 200
response_data = json.loads(response.get_data())
assert response_data["disable_ui_telemetry"] is True
assert response_data["ui_rollout_percentage"] == 0
assert response_data["disable_ui_events"] == []
def test_post_ui_telemetry_handler_success(
test_app, mock_telemetry_config_cache, bypass_telemetry_env_check
):
event1 = {
"event_name": "test_event_1",
"timestamp_ns": 1234567890000000,
"params": {"key1": "value1"},
"installation_id": "install-123",
"session_id": "session-456",
}
event2 = {
"event_name": "test_event_2",
"timestamp_ns": 1234567890000001,
"params": {"key2": "value2"},
"installation_id": "install-123",
"session_id": "session-456",
}
request = json.dumps({"records": [event1, event2]})
config = {"disable_ui_telemetry": False, "disable_telemetry": False}
mock_client = mock.MagicMock()
server_install_id = "server-install-789"
with (
test_app.test_request_context(
"/ui-telemetry", method="POST", data=request, content_type="application/json"
),
mock.patch("mlflow.server.handlers.fetch_ui_telemetry_config", return_value=config),
mock.patch("mlflow.server.handlers.get_telemetry_client", return_value=mock_client),
mock.patch(
"mlflow.server.handlers.get_or_create_installation_id",
return_value=server_install_id,
),
):
response = post_ui_telemetry_handler()
assert response is not None
assert response.status_code == 200
response_data = json.loads(response.get_data())
assert response_data["status"] == "success"
assert mock_client.add_records.call_count == 1
assert mock_client.add_records.call_args[0][0] == [
Record(
**event1,
duration_ms=0,
status=Status.SUCCESS,
server_installation_id=server_install_id,
),
Record(
**event2,
duration_ms=0,
status=Status.SUCCESS,
server_installation_id=server_install_id,
),
]
def test_post_ui_telemetry_handler_telemetry_disabled_by_config(
test_app, mock_telemetry_config_cache, bypass_telemetry_env_check
):
event = {
"event_name": "test_event_1",
"timestamp_ns": 1234567890000000,
"params": {"key1": "value1"},
"installation_id": "install-123",
"session_id": "session-456",
}
request = json.dumps({"records": [event]})
config = {"disable_ui_telemetry": True}
mock_client = mock.MagicMock()
with (
test_app.test_request_context(
"/ui-telemetry", method="POST", data=request, content_type="application/json"
),
mock.patch("mlflow.server.handlers.fetch_ui_telemetry_config", return_value=config),
mock.patch("mlflow.server.handlers.get_telemetry_client", return_value=mock_client),
):
response = post_ui_telemetry_handler()
assert response is not None
assert response.status_code == 200
response_data = json.loads(response.get_data())
assert response_data["status"] == "disabled"
mock_client.add_record.assert_not_called()
def test_post_ui_telemetry_handler_telemetry_disabled_by_env(
test_app, mock_telemetry_config_cache, bypass_telemetry_env_check, monkeypatch
):
monkeypatch.setenv("DO_NOT_TRACK", "true")
request = json.dumps({"records": []})
with (
test_app.test_request_context(
"/ui-telemetry", method="POST", data=request, content_type="application/json"
),
mock.patch("mlflow.server.handlers.fetch_ui_telemetry_config") as mock_fetch,
mock.patch("mlflow.server.handlers.get_telemetry_client") as mock_get_client,
):
response = post_ui_telemetry_handler()
assert response is not None
assert response.status_code == 200
response_data = json.loads(response.get_data())
assert response_data["status"] == "disabled"
# assert that no fetch happens and no client is retrieved
mock_fetch.assert_not_called()
mock_get_client.assert_not_called()
def test_send_artifact_prefers_local_path(tmp_path):
artifact_path = "test_model/model.pkl"
test_data = b"local artifact"
test_file = tmp_path / "model.pkl"
test_file.write_bytes(test_data)
mock_artifact_repo = mock.MagicMock()
mock_artifact_repo.get_local_path.return_value = str(test_file)
with (
app.test_request_context(method="GET"),
mock.patch("mlflow.server.handlers.tempfile.TemporaryDirectory") as mock_tmp_dir,
):
response = _send_artifact(mock_artifact_repo, artifact_path)
response.direct_passthrough = False
assert response.get_data() == test_data
assert response.headers["Content-Disposition"] == "attachment; filename=model.pkl"
mock_artifact_repo.get_local_path.assert_called_once_with(artifact_path)
mock_artifact_repo.download_artifacts.assert_not_called()
mock_tmp_dir.assert_not_called()
def test_send_artifact_falls_back_to_download_when_local_path_unavailable(tmp_path):
artifact_path = "test_model/model.pkl"
test_data = b"downloaded artifact"
test_file = tmp_path / "model.pkl"
test_file.write_bytes(test_data)
mock_artifact_repo = mock.MagicMock()
mock_artifact_repo.get_local_path.return_value = None
mock_artifact_repo.download_artifacts.return_value = str(test_file)
with app.test_request_context(method="GET"):
response = _send_artifact(mock_artifact_repo, artifact_path)
response.direct_passthrough = False
assert response.get_data() == test_data
assert response.headers["Content-Disposition"] == "attachment; filename=model.pkl"
mock_artifact_repo.get_local_path.assert_called_once_with(artifact_path)
mock_artifact_repo.download_artifacts.assert_called_once_with(artifact_path, dst_path=mock.ANY)
def test_create_artifact_file_response_uses_local_path_mimetype_and_artifact_name(tmp_path):
test_file = tmp_path / "payload.html"
test_file.write_text("<html><body>ok</body></html>")
with app.test_request_context(method="GET"):
response = _create_artifact_file_response(str(test_file), "artifacts/model.txt")
assert response.mimetype == "text/html"
assert response.headers["Content-Disposition"] == "attachment; filename=model.txt"
def test_create_artifact_file_response_quotes_token_unsafe_ascii_artifact_name(tmp_path):
test_file = tmp_path / "payload.html"
test_file.write_text("<html><body>ok</body></html>")
with app.test_request_context(method="GET"):
response = _create_artifact_file_response(str(test_file), "artifacts/my model;a.txt")
assert response.headers["Content-Disposition"] == 'attachment; filename="my model;a.txt"'
def test_download_artifact_uses_local_path_fast_path(enable_serve_artifacts, tmp_path):
artifact_path = "test_model/model.pkl"
test_data = b"local artifact"
test_file = tmp_path / "model.pkl"
test_file.write_bytes(test_data)
with (
app.test_request_context(method="GET"),
mock.patch("mlflow.server.handlers._get_artifact_repo_mlflow_artifacts") as mock_repo,
mock.patch("mlflow.server.handlers.tempfile.TemporaryDirectory") as mock_tmp_dir,
):
mock_tmp_dir_instance = mock.MagicMock()
mock_tmp_dir.return_value = mock_tmp_dir_instance
mock_artifact_repo = mock.MagicMock()
mock_artifact_repo.get_local_path.return_value = str(test_file)
mock_repo.return_value = mock_artifact_repo
response = _download_artifact(artifact_path)
response.direct_passthrough = False
assert response.get_data() == test_data
assert response.headers["Content-Disposition"] == "attachment; filename=model.pkl"
mock_artifact_repo.get_local_path.assert_called_once_with(artifact_path)
mock_artifact_repo.download_artifacts.assert_not_called()
mock_tmp_dir.assert_not_called()
def test_upload_artifact_uses_stream_upload_when_mixin_supported(enable_serve_artifacts):
artifact_path = "nested/model.pkl"
test_data = b"streamed artifact"
with (
app.test_request_context(
method="PUT", data=test_data, content_type="application/octet-stream"
),
mock.patch("mlflow.server.handlers._get_artifact_repo_mlflow_artifacts") as mock_repo,
):
mock_artifact_repo = mock.MagicMock(spec=LocalArtifactRepository)
mock_repo.return_value = mock_artifact_repo
response = _upload_artifact(artifact_path)
mock_artifact_repo.log_artifact_from_stream.assert_called_once()
args, kwargs = mock_artifact_repo.log_artifact_from_stream.call_args
assert args[1] == "model.pkl"
assert kwargs["artifact_path"] == "nested"
assert response.status_code == 200
def test_upload_artifact_falls_back_to_log_artifact_without_mixin(enable_serve_artifacts):
artifact_path = "nested/model.pkl"
test_data = b"streamed artifact"
with (
app.test_request_context(
method="PUT", data=test_data, content_type="application/octet-stream"
),
mock.patch("mlflow.server.handlers._get_artifact_repo_mlflow_artifacts") as mock_repo,
):
mock_artifact_repo = mock.MagicMock(spec=ArtifactRepository)
mock_repo.return_value = mock_artifact_repo
response = _upload_artifact(artifact_path)
mock_artifact_repo.log_artifact.assert_called_once()
args, kwargs = mock_artifact_repo.log_artifact.call_args
assert args[0].endswith("model.pkl")
assert kwargs["artifact_path"] == "nested"
assert response.status_code == 200
def test_download_artifact_streams_in_chunks(enable_serve_artifacts, tmp_path):
# Create a test file with binary data larger than the chunk size (2MB + 1000 bytes)
test_file_size = ARTIFACT_STREAM_CHUNK_SIZE * 2 + 1000
test_data = b"x" * test_file_size
artifact_path = "test_model/model.pkl"
test_file = tmp_path / "model.pkl"
test_file.write_bytes(test_data)
with (
app.test_request_context(method="GET"),
mock.patch("mlflow.server.handlers._get_artifact_repo_mlflow_artifacts") as mock_repo,
mock.patch("mlflow.server.handlers.tempfile.TemporaryDirectory") as mock_tmp_dir,
):
# Setup mocks
mock_tmp_dir_instance = mock.MagicMock()
mock_tmp_dir_instance.name = str(tmp_path)
mock_tmp_dir.return_value = mock_tmp_dir_instance
mock_artifact_repo = mock.MagicMock()
mock_artifact_repo.get_local_path.return_value = None
mock_artifact_repo.download_artifacts.return_value = str(test_file)
mock_repo.return_value = mock_artifact_repo
# Call the function and capture the response
response = _download_artifact(artifact_path)
# Extract chunks from the response by iterating over its data
response_chunks = list(response.response)
# Verify that data was streamed in chunks, not line by line
# For a 2MB+ binary file, line-by-line would produce many small chunks
# Chunk-based streaming should produce exactly 3 chunks (2*1MB + 1000 bytes)
assert len(response_chunks) == 3, f"Expected 3 chunks, got {len(response_chunks)}"
# Verify chunk sizes
assert len(response_chunks[0]) == ARTIFACT_STREAM_CHUNK_SIZE
assert len(response_chunks[1]) == ARTIFACT_STREAM_CHUNK_SIZE
assert len(response_chunks[2]) == 1000
# Verify that all data is correctly streamed
streamed_data = b"".join(response_chunks)
assert streamed_data == test_data
mock_artifact_repo.get_local_path.assert_called_once_with(artifact_path)
mock_artifact_repo.download_artifacts.assert_called_once_with(artifact_path, str(tmp_path))
def test_download_artifact_cleans_up_tmp_dir_when_download_fails(enable_serve_artifacts, tmp_path):
artifact_path = "test_model/model.pkl"
with (
app.test_request_context(method="GET"),
mock.patch("mlflow.server.handlers._get_artifact_repo_mlflow_artifacts") as mock_repo,
mock.patch("mlflow.server.handlers.tempfile.TemporaryDirectory") as mock_tmp_dir,
):
mock_tmp_dir_instance = mock.MagicMock()
mock_tmp_dir_instance.name = str(tmp_path)
mock_tmp_dir.return_value = mock_tmp_dir_instance
mock_artifact_repo = mock.MagicMock()
mock_artifact_repo.get_local_path.return_value = None
mock_artifact_repo.download_artifacts.side_effect = RuntimeError("boom")
mock_repo.return_value = mock_artifact_repo
with pytest.raises(RuntimeError, match="boom"):
_download_artifact(artifact_path)
mock_artifact_repo.get_local_path.assert_called_once_with(artifact_path)
mock_artifact_repo.download_artifacts.assert_called_once_with(artifact_path, str(tmp_path))
mock_tmp_dir_instance.cleanup.assert_called_once()
def test_download_artifact_returns_404_for_missing_azure_blob(enable_serve_artifacts):
ResourceNotFoundError = pytest.importorskip("azure.core.exceptions").ResourceNotFoundError
artifact_repo = AzureBlobArtifactRepository(
"wasbs://container@account.blob.core.windows.net/root",
client=mock.MagicMock(),
)
artifact_repo.client.get_container_client().walk_blobs.return_value = []
artifact_repo.client.get_container_client().download_blob.side_effect = ResourceNotFoundError(
"Operation returned an invalid status: BlobNotFound"
)
with (
app.test_request_context(method="GET"),
mock.patch(
"mlflow.server.handlers._get_artifact_repo_mlflow_artifacts",
return_value=artifact_repo,
),
):
response = _download_artifact("missing.txt")
artifact_repo.thread_pool.shutdown()
assert response.status_code == 404
assert json.loads(response.get_data())["error_code"] == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
@pytest.mark.parametrize(
("file_path", "expected_simple", "expected_quoted"),
[
# No-extension fully-CJK filename: NFKD normalization strips every
# character, so the helper must fall back to a safe non-empty
# ``filename=`` value rather than emitting ``filename=;``.
("日本語", "download", "%E6%97%A5%E6%9C%AC%E8%AA%9E"),
("Tribeč_mountains.html", "Tribec_mountains.html", "Tribe%C4%8D_mountains.html"),
(
"time_series_eeeúaaa_aaaaaal_39.html",
"time_series_eeeuaaa_aaaaaal_39.html",
"time_series_eee%C3%BAaaa_aaaaaal_39.html",
),
("日本語.txt", ".txt", "%E6%97%A5%E6%9C%AC%E8%AA%9E.txt"),
],
)
def test_response_with_file_attachment_headers_encodes_non_ascii_filename(
file_path, expected_simple, expected_quoted
):
with app.test_request_context():
response = _response_with_file_attachment_headers(file_path, Response())
header = response.headers["Content-Disposition"]
# The Content-Disposition header value must be ASCII-encodable so that
# WSGI/ASGI adapters (e.g., starlette's WSGIMiddleware) can serialize
# the response without raising UnicodeEncodeError. See GH-23208.
header.encode("ascii")
assert header == f"attachment; filename={expected_simple}; filename*=UTF-8''{expected_quoted}"
@pytest.mark.parametrize(
("filename", "expected_header"),
[
("model.pkl", "attachment; filename=model.pkl"),
("my model;a.txt", 'attachment; filename="my model;a.txt"'),
],
)
def test_response_with_file_attachment_headers_ascii_filename_preserves_werkzeug_quoting(
filename, expected_header
):
with app.test_request_context():
response = _response_with_file_attachment_headers(filename, Response())
assert response.headers["Content-Disposition"] == expected_header
@pytest.mark.parametrize(
("filename", "expected_simple", "expected_quoted"),
[
# See sibling unit test for why this empty-fallback case leads.
("日本語", "download", "%E6%97%A5%E6%9C%AC%E8%AA%9E"),
("Tribeč_mountains.html", "Tribec_mountains.html", "Tribe%C4%8D_mountains.html"),
(
"time_series_eeeúaaa_aaaaaal_39.html",
"time_series_eeeuaaa_aaaaaal_39.html",
"time_series_eee%C3%BAaaa_aaaaaal_39.html",
),
("日本語.txt", ".txt", "%E6%97%A5%E6%9C%AC%E8%AA%9E.txt"),
],
)
def test_download_artifact_endpoint_non_ascii_filename(
enable_serve_artifacts, monkeypatch, tmp_path, filename, expected_simple, expected_quoted
):
# End-to-end coverage for the `_download_artifact` HTTP path. Exercises the
# full `/api/2.0/mlflow-artifacts/artifacts/<artifact_path>` route to guard
# against regressions where the WSGI/ASGI adapter serializes the
# `Content-Disposition` header and rejects raw non-ASCII bytes. See GH-23208.
artifact_root = tmp_path / "artifacts"
artifact_root.mkdir()
file_contents = b"hello from " + filename.encode("utf-8")
(artifact_root / filename).write_bytes(file_contents)
# ``.as_uri()`` not ``str()``: on Windows ``str(WindowsPath)`` is ``C:\...``
# which mlflow's artifact registry parses as scheme=``C`` and 500s.
monkeypatch.setenv(ARTIFACTS_DESTINATION_ENV_VAR, artifact_root.as_uri())
monkeypatch.setattr("mlflow.server.handlers._artifact_repo", None)
quoted_path = urllib.parse.quote(filename)
with app.test_client() as c:
response = c.get(f"/api/2.0/mlflow-artifacts/artifacts/{quoted_path}")
assert response.status_code == 200
assert response.get_data() == file_contents
header = response.headers["Content-Disposition"]
header.encode("ascii")
assert header == f"attachment; filename={expected_simple}; filename*=UTF-8''{expected_quoted}"
def test_create_prompt_optimization_job(mock_tracking_store):
mock_job_entity = JobEntity(
job_id="job-123",
creation_time=1234567890,
job_name="optimize_prompts",
params='{"run_id": "run-456"}',
timeout=None,
status=JobStatus.PENDING,
result=None,
retry_count=0,
last_update_time=1234567890,
status_details=None,
)
mock_run = mock.MagicMock()
mock_run.info.run_id = "run-456"
mock_tracking_store.create_run.return_value = mock_run
mock_dataset = mock.MagicMock()
mock_dataset._to_mlflow_entity.return_value = mock.MagicMock()
with (
mock.patch("mlflow.server.jobs.submit_job", return_value=mock_job_entity),
mock.patch("mlflow.server.handlers._get_user", return_value="test_user"),
mock.patch(
"mlflow.genai.datasets.get_dataset", return_value=mock_dataset
) as mock_get_dataset,
):
with app.test_request_context(
method="POST",
json={
"experiment_id": "exp-123",
"source_prompt_uri": "prompts:/my-prompt/1",
"config": {
"optimizer_type": OPTIMIZER_TYPE_GEPA,
"dataset_id": "dataset-123",
"scorers": ["Correctness", "Safety"],
"optimizer_config_json": '{"reflection_model": "openai:/gpt-4"}',
},
"tags": [{"key": "env", "value": "test"}],
},
):
response = _create_prompt_optimization_job()
mock_get_dataset.assert_called_once_with(dataset_id="dataset-123")
mock_tracking_store.create_run.assert_called_once()
call_kwargs = mock_tracking_store.create_run.call_args[1]
assert call_kwargs["experiment_id"] == "exp-123"
assert call_kwargs["user_id"] == "test_user"
mock_tracking_store.log_batch.assert_called_once()
logged_params = mock_tracking_store.log_batch.call_args[1]["params"]
param_dict = {p.key: p.value for p in logged_params}
assert param_dict["source_prompt_uri"] == "prompts:/my-prompt/1"
assert param_dict["optimizer_type"] == "gepa"
assert param_dict["dataset_id"] == "dataset-123"
assert param_dict["scorer_names"] == '["Correctness", "Safety"]'
response_data = json.loads(response.get_data())
assert response_data["job"]["job_id"] == "job-123"
assert response_data["job"]["run_id"] == "run-456"
assert response_data["job"]["state"]["status"] == "JOB_STATUS_PENDING"
assert response_data["job"]["experiment_id"] == "exp-123"
assert response_data["job"]["source_prompt_uri"] == "prompts:/my-prompt/1"
def test_create_prompt_optimization_job_zero_shot(mock_tracking_store):
mock_job_entity = JobEntity(
job_id="job-999",
creation_time=1234567890,
job_name="optimize_prompts",
params='{"run_id": "run-999"}',
timeout=None,
status=JobStatus.PENDING,
result=None,
retry_count=0,
last_update_time=1234567890,
status_details=None,
)
mock_run = mock.MagicMock()
mock_run.info.run_id = "run-999"
mock_tracking_store.create_run.return_value = mock_run
with (
mock.patch("mlflow.server.jobs.submit_job", return_value=mock_job_entity),
mock.patch("mlflow.server.handlers._get_user", return_value="test_user"),
):
with app.test_request_context(
method="POST",
json={
"experiment_id": "exp-123",
"source_prompt_uri": "prompts:/my-prompt/1",
"config": {
"optimizer_type": OPTIMIZER_TYPE_METAPROMPT,
"scorers": [], # Empty scorers for zero-shot
# No dataset_id - zero-shot optimization
},
},
):
response = _create_prompt_optimization_job()
response_data = json.loads(response.get_data())
assert response_data["job"]["job_id"] == "job-999"
assert response_data["job"]["run_id"] == "run-999"
assert response_data["job"]["state"]["status"] == "JOB_STATUS_PENDING"
mock_tracking_store.log_batch.assert_called_once()
logged_params = mock_tracking_store.log_batch.call_args[1]["params"]
param_dict = {p.key: p.value for p in logged_params}
assert param_dict["dataset_id"] == "" # Empty string for None
assert param_dict["scorer_names"] == "[]" # Empty list
def test_create_prompt_optimization_job_missing_prompt_uri(mock_tracking_store):
with app.test_request_context(
method="POST",
json={
"experiment_id": "exp-123",
"config": {
"optimizer_type": 1,
"dataset_id": "dataset-123",
"scorers": ["Correctness"],
},
},
):
response = _create_prompt_optimization_job()
assert response.status_code == 400
json_response = json.loads(response.get_data())
assert json_response["error_code"] == ErrorCode.Name(INVALID_PARAMETER_VALUE)
assert "source_prompt_uri" in json_response["message"]
def test_create_prompt_optimization_job_unspecified_optimizer_type(mock_tracking_store):
with app.test_request_context(
method="POST",
json={
"experiment_id": "exp-123",
"source_prompt_uri": "prompts:/my-prompt/1",
"config": {
"optimizer_type": OPTIMIZER_TYPE_UNSPECIFIED,
"dataset_id": "dataset-123",
"scorers": ["Correctness"],
},
},
):
response = _create_prompt_optimization_job()
assert response.status_code == 400
json_response = json.loads(response.get_data())
assert json_response["error_code"] == ErrorCode.Name(INVALID_PARAMETER_VALUE)
assert "optimizer_type is required" in json_response["message"]
def test_create_prompt_optimization_job_invalid_optimizer_config_json(mock_tracking_store):
with app.test_request_context(
method="POST",
json={
"experiment_id": "exp-123",
"source_prompt_uri": "prompts:/my-prompt/1",
"config": {
"optimizer_type": 1,
"dataset_id": "dataset-123",
"scorers": ["Correctness"],
"optimizer_config_json": "invalid json {",
},
},
):
response = _create_prompt_optimization_job()
assert response.status_code == 400
json_response = json.loads(response.get_data())
assert json_response["error_code"] == ErrorCode.Name(INVALID_PARAMETER_VALUE)
assert "Invalid JSON in optimizer_config_json" in json_response["message"]
def test_create_prompt_optimization_job_missing_experiment_id(mock_tracking_store):
with app.test_request_context(
method="POST",
json={
"experiment_id": "", # Empty experiment_id
"source_prompt_uri": "prompts:/my-prompt/1",
"config": {
"optimizer_type": 1,
"dataset_id": "dataset-123",
"scorers": ["Correctness"],
},
},
):
response = _create_prompt_optimization_job()
assert response.status_code == 400
json_response = json.loads(response.get_data())
assert json_response["error_code"] == ErrorCode.Name(INVALID_PARAMETER_VALUE)
assert "experiment_id is required" in json_response["message"]
def test_cancel_prompt_optimization_job():
mock_job_entity = JobEntity(
job_id="job-123",
creation_time=1234567890,
job_name="optimize_prompts",
params=(
'{"experiment_id": "exp-123", "prompt_uri": "prompts:/my-prompt/1", '
'"run_id": "run-456"}'
),
timeout=None,
status=JobStatus.CANCELED,
result=None,
retry_count=0,
last_update_time=1234567890,
status_details=None,
)
with (
mock.patch("mlflow.server.jobs.cancel_job", return_value=mock_job_entity),
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
):
mock_tracking_store = mock.Mock()
mock_store.return_value = mock_tracking_store
with app.test_request_context(method="POST"):
response = _cancel_prompt_optimization_job("job-123")
# Verify that the underlying run was terminated
mock_tracking_store.update_run_info.assert_called_once()
call_args = mock_tracking_store.update_run_info.call_args
assert call_args.kwargs["run_id"] == "run-456"
assert call_args.kwargs["run_status"] == RunStatus.KILLED
assert call_args.kwargs["run_name"] is None
assert "end_time" in call_args.kwargs
response_data = json.loads(response.get_data())
assert response_data["job"]["job_id"] == "job-123"
assert response_data["job"]["state"]["status"] == "JOB_STATUS_CANCELED"
assert response_data["job"]["experiment_id"] == "exp-123"
assert response_data["job"]["source_prompt_uri"] == "prompts:/my-prompt/1"
assert response_data["job"]["run_id"] == "run-456"
def test_get_prompt_optimization_job_pending(mock_tracking_store):
mock_job = _create_mock_job(status_name="PENDING")
mock_run = _create_mock_run(
params={
"source_prompt_uri": "prompts:/my-prompt/1",
"optimizer_type": "gepa",
"dataset_id": "dataset-789",
"scorer_names": '["Correctness"]',
}
)
mock_tracking_store.get_run.return_value = mock_run
with mock.patch("mlflow.server.jobs.get_job", return_value=mock_job):
with app.test_client() as c:
response = c.get("/ajax-api/3.0/mlflow/prompt-optimization/jobs/job-123")
assert response.status_code == 200
data = response.get_json()
assert "job" in data
job = data["job"]
assert job["job_id"] == "job-123"
assert job["run_id"] == "run-456"
assert job["experiment_id"] == "exp-123"
assert job["source_prompt_uri"] == "prompts:/my-prompt/1"
assert job["state"]["status"] == "JOB_STATUS_PENDING"
def test_get_prompt_optimization_job_succeeded_with_result(mock_tracking_store):
mock_job = _create_mock_job(
status_name="SUCCEEDED",
result={"optimized_prompt_uri": "prompts:/my-prompt/2"},
)
mock_run = _create_mock_run(
params={
"source_prompt_uri": "prompts:/my-prompt/1",
"optimizer_type": "gepa",
"dataset_id": "dataset-789",
"scorer_names": '["Correctness", "Safety"]',
},
metrics={
"initial_eval_score.Correctness": 0.65,
"initial_eval_score.Safety": 0.80,
"final_eval_score.Correctness": 0.89,
"final_eval_score.Safety": 0.95,
},
)
mock_tracking_store.get_run.return_value = mock_run
with mock.patch("mlflow.server.jobs.get_job", return_value=mock_job):
with app.test_client() as c:
response = c.get("/ajax-api/3.0/mlflow/prompt-optimization/jobs/job-123")
assert response.status_code == 200
data = response.get_json()
job = data["job"]
assert job["state"]["status"] == "JOB_STATUS_COMPLETED"
assert job["optimized_prompt_uri"] == "prompts:/my-prompt/2"
# Verify metrics are populated from the run
assert job["initial_eval_scores"]["Correctness"] == 0.65
assert job["initial_eval_scores"]["Safety"] == 0.80
assert job["final_eval_scores"]["Correctness"] == 0.89
assert job["final_eval_scores"]["Safety"] == 0.95
def test_get_prompt_optimization_job_succeeded_run_fetch_fails(mock_tracking_store):
mock_job = _create_mock_job(
status_name="SUCCEEDED",
result={"optimized_prompt_uri": "prompts:/my-prompt/2"},
)
# Simulate run fetch failing (e.g., run not found)
mock_tracking_store.get_run.side_effect = Exception("Run not found")
with mock.patch("mlflow.server.jobs.get_job", return_value=mock_job):
with app.test_client() as c:
response = c.get("/ajax-api/3.0/mlflow/prompt-optimization/jobs/job-123")
assert response.status_code == 200
data = response.get_json()
job = data["job"]
assert job["state"]["status"] == "JOB_STATUS_COMPLETED"
assert job["optimized_prompt_uri"] == "prompts:/my-prompt/2"
# Metrics should not be present when run fetch fails
assert "initial_eval_scores" not in job or job["initial_eval_scores"] == {}
def test_get_prompt_optimization_job_failed_with_error(mock_tracking_store):
mock_job = _create_mock_job(
status_name="FAILED",
result="Optimization failed: Invalid scorer",
)
mock_run = _create_mock_run()
mock_tracking_store.get_run.return_value = mock_run
with mock.patch("mlflow.server.jobs.get_job", return_value=mock_job):
with app.test_client() as c:
response = c.get("/ajax-api/3.0/mlflow/prompt-optimization/jobs/job-123")
assert response.status_code == 200
data = response.get_json()
job = data["job"]
assert job["state"]["status"] == "JOB_STATUS_FAILED"
assert "Optimization failed" in job["state"]["error_message"]
def test_get_prompt_optimization_job_without_run_id(mock_tracking_store):
mock_job = _create_mock_job(
params={"experiment_id": "exp-123", "prompt_uri": "prompts:/my-prompt/1"}
)
with mock.patch("mlflow.server.jobs.get_job", return_value=mock_job):
with app.test_client() as c:
response = c.get("/ajax-api/3.0/mlflow/prompt-optimization/jobs/job-123")
assert response.status_code == 200
data = response.get_json()
job = data["job"]
assert job["job_id"] == "job-123"
assert job["experiment_id"] == "exp-123"
assert "run_id" not in job # run_id is not set
def test_get_prompt_optimization_job_with_progress(mock_tracking_store):
mock_job = _create_mock_job(
status_name="RUNNING",
params={
"experiment_id": "exp-123",
"prompt_uri": "prompts:/my-prompt/1",
"run_id": "run-456",
"optimizer_config": {"max_metric_calls": 200, "reflection_model": "openai:/gpt-4o"},
},
)
mock_run = _create_mock_run(
params={
"source_prompt_uri": "prompts:/my-prompt/1",
"optimizer_type": "gepa",
},
metrics={
"total_metric_calls": 86,
"eval_score": 0.75,
},
)
mock_tracking_store.get_run.return_value = mock_run
with mock.patch("mlflow.server.jobs.get_job", return_value=mock_job):
with app.test_client() as c:
response = c.get("/ajax-api/3.0/mlflow/prompt-optimization/jobs/job-123")
assert response.status_code == 200
data = response.get_json()
job = data["job"]
assert job["state"]["status"] == "JOB_STATUS_IN_PROGRESS"
# Progress should be 86 / 200 = 0.43
assert job["state"]["metadata"]["progress"] == "0.43"
def test_get_prompt_optimization_job_progress_capped_at_one(mock_tracking_store):
mock_job = _create_mock_job(
status_name="RUNNING",
params={
"experiment_id": "exp-123",
"prompt_uri": "prompts:/my-prompt/1",
"run_id": "run-456",
"optimizer_config": {"max_metric_calls": 100, "reflection_model": "openai:/gpt-4o"},
},
)
mock_run = _create_mock_run(
metrics={
"total_metric_calls": 150, # Exceeds max_metric_calls
},
)
mock_tracking_store.get_run.return_value = mock_run
with mock.patch("mlflow.server.jobs.get_job", return_value=mock_job):
with app.test_client() as c:
response = c.get("/ajax-api/3.0/mlflow/prompt-optimization/jobs/job-123")
assert response.status_code == 200
data = response.get_json()
job = data["job"]
# Progress should be capped at 1.0, not 1.5
assert job["state"]["metadata"]["progress"] == "1.0"
def test_get_prompt_optimization_job_no_progress_without_max_metric_calls(mock_tracking_store):
mock_job = _create_mock_job(
status_name="RUNNING",
params={
"experiment_id": "exp-123",
"prompt_uri": "prompts:/my-prompt/1",
"run_id": "run-456",
"optimizer_config": {"reflection_model": "openai:/gpt-4o"},
},
)
mock_run = _create_mock_run(
metrics={
"total_metric_calls": 50,
},
)
mock_tracking_store.get_run.return_value = mock_run
with mock.patch("mlflow.server.jobs.get_job", return_value=mock_job):
with app.test_client() as c:
response = c.get("/ajax-api/3.0/mlflow/prompt-optimization/jobs/job-123")
assert response.status_code == 200
data = response.get_json()
job = data["job"]
# Progress should NOT be set when max_metric_calls is not configured
assert "progress" not in job["state"].get("status_details", {})
def test_search_prompt_optimization_jobs_returns_multiple_jobs(mock_job_store):
mock_jobs = [
_create_mock_job(
job_id="job-1",
status_name="SUCCEEDED",
result={"optimized_prompt_uri": "prompts:/opt/1"},
),
_create_mock_job(job_id="job-2", status_name="RUNNING"),
_create_mock_job(job_id="job-3", status_name="PENDING"),
]
mock_job_store.list_jobs.return_value = iter(mock_jobs)
with app.test_client() as c:
response = c.post(
"/ajax-api/3.0/mlflow/prompt-optimization/jobs/search",
json={"experiment_id": "exp-123"},
)
assert response.status_code == 200
data = response.get_json()
assert "jobs" in data
assert len(data["jobs"]) == 3
job_ids = [job["job_id"] for job in data["jobs"]]
assert "job-1" in job_ids
assert "job-2" in job_ids
assert "job-3" in job_ids
mock_job_store.list_jobs.assert_called_once_with(
job_name="optimize_prompts",
params={"experiment_id": "exp-123"},
)
def test_search_prompt_optimization_jobs_returns_empty_list(mock_job_store):
mock_job_store.list_jobs.return_value = iter([])
with app.test_client() as c:
response = c.post(
"/ajax-api/3.0/mlflow/prompt-optimization/jobs/search",
json={"experiment_id": "exp-456"},
)
assert response.status_code == 200
data = response.get_json()
assert data.get("jobs", []) == []
def test_search_prompt_optimization_jobs_missing_experiment_id():
with app.test_client() as c:
response = c.post(
"/ajax-api/3.0/mlflow/prompt-optimization/jobs/search",
json={},
)
assert response.status_code == 400
def test_search_prompt_optimization_jobs_includes_succeeded_job_result(mock_job_store):
mock_job = _create_mock_job(
job_id="job-1",
status_name="SUCCEEDED",
result={"optimized_prompt_uri": "prompts:/optimized/1"},
)
mock_job_store.list_jobs.return_value = iter([mock_job])
with app.test_client() as c:
response = c.post(
"/ajax-api/3.0/mlflow/prompt-optimization/jobs/search",
json={"experiment_id": "exp-123"},
)
assert response.status_code == 200
data = response.get_json()
assert len(data["jobs"]) == 1
assert data["jobs"][0]["optimized_prompt_uri"] == "prompts:/optimized/1"
def test_search_prompt_optimization_jobs_includes_failed_job_error(mock_job_store):
mock_job = _create_mock_job(
job_id="job-1",
status_name="FAILED",
result="Some error occurred",
)
mock_job_store.list_jobs.return_value = iter([mock_job])
with app.test_client() as c:
response = c.post(
"/ajax-api/3.0/mlflow/prompt-optimization/jobs/search",
json={"experiment_id": "exp-123"},
)
assert response.status_code == 200
data = response.get_json()
assert len(data["jobs"]) == 1
assert "Some error occurred" in data["jobs"][0]["state"]["error_message"]
def test_delete_prompt_optimization_job_success(mock_job_store, mock_tracking_store):
mock_job = _create_mock_job(
status_name="SUCCEEDED",
result={"optimized_prompt_uri": "prompts:/optimized/1"},
)
mock_job_store.get_job.return_value = mock_job
mock_tracking_store.get_run.return_value = mock.MagicMock() # Run exists
with app.test_client() as c:
response = c.delete("/ajax-api/3.0/mlflow/prompt-optimization/jobs/job-123")
assert response.status_code == 200
mock_job_store.delete_jobs.assert_called_once_with(job_ids=["job-123"])
mock_tracking_store.get_run.assert_called_once_with("run-456")
mock_tracking_store.delete_run.assert_called_once_with("run-456")
def test_delete_prompt_optimization_job_without_run_id(mock_job_store, mock_tracking_store):
mock_job = _create_mock_job(
params={"experiment_id": "exp-123", "prompt_uri": "prompts:/my-prompt/1"}
)
mock_job_store.get_job.return_value = mock_job
with app.test_client() as c:
response = c.delete("/ajax-api/3.0/mlflow/prompt-optimization/jobs/job-123")
assert response.status_code == 200
mock_job_store.delete_jobs.assert_called_once_with(job_ids=["job-123"])
mock_tracking_store.delete_run.assert_not_called()
def test_delete_prompt_optimization_job_skips_run_deletion_when_run_not_found(
mock_job_store, mock_tracking_store
):
mock_job = _create_mock_job(
status_name="SUCCEEDED",
result={"optimized_prompt_uri": "prompts:/optimized/1"},
)
mock_job_store.get_job.return_value = mock_job
mock_tracking_store.get_run.side_effect = MlflowException("Run not found")
with app.test_client() as c:
response = c.delete("/ajax-api/3.0/mlflow/prompt-optimization/jobs/job-123")
assert response.status_code == 200
mock_job_store.delete_jobs.assert_called_once_with(job_ids=["job-123"])
# delete_run should not be called since run doesn't exist
mock_tracking_store.delete_run.assert_not_called()
def test_get_workspace_scoped_repo_path_if_enabled_allows_legacy_default_artifacts(monkeypatch):
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
with WorkspaceContext(DEFAULT_WORKSPACE_NAME):
assert (
_get_workspace_scoped_repo_path_if_enabled("1/legacy/artifact") == "1/legacy/artifact"
)
def test_get_workspace_scoped_repo_path_if_enabled_still_scopes_non_default(monkeypatch):
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
with WorkspaceContext("team-blue"):
scoped = _get_workspace_scoped_repo_path_if_enabled("2/new/artifact")
assert scoped.startswith("workspaces/team-blue/2/new/artifact")
def test_get_workspace_scoped_repo_path_if_enabled_prevents_cross_workspace_access(monkeypatch):
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
with WorkspaceContext("team-a"):
with pytest.raises(MlflowException, match="targets workspace 'team-b'"):
_get_workspace_scoped_repo_path_if_enabled("workspaces/team-b/secret.txt")
with pytest.raises(MlflowException, match="targets workspace 'other'"):
_get_workspace_scoped_repo_path_if_enabled("workspaces/other/data/model.pkl")
def test_get_workspace_scoped_repo_path_if_enabled_rejects_empty_workspace_in_path(monkeypatch):
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
with WorkspaceContext("team-a"):
with pytest.raises(MlflowException, match="must include a workspace name"):
_get_workspace_scoped_repo_path_if_enabled("workspaces/")
with pytest.raises(MlflowException, match="must include a workspace name"):
_get_workspace_scoped_repo_path_if_enabled("workspaces//data.txt")
def test_get_workspace_scoped_repo_path_if_enabled_allows_matching_workspace_prefix(monkeypatch):
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
with WorkspaceContext("team-a"):
result = _get_workspace_scoped_repo_path_if_enabled("workspaces/team-a/data.txt")
assert result == "workspaces/team-a/data.txt"
result = _get_workspace_scoped_repo_path_if_enabled("/workspaces/team-a/nested/path")
assert result == "workspaces/team-a/nested/path"
def test_get_workspace_scoped_repo_path_if_enabled_default_workspace_cross_access_blocked(
monkeypatch,
):
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
with WorkspaceContext(DEFAULT_WORKSPACE_NAME):
result = _get_workspace_scoped_repo_path_if_enabled("legacy/artifact.txt")
assert result == "legacy/artifact.txt"
with pytest.raises(MlflowException, match="targets workspace 'team-b'"):
_get_workspace_scoped_repo_path_if_enabled("workspaces/team-b/data.txt")
def test_get_workspace_scoped_repo_path_if_enabled_requires_active_workspace(monkeypatch):
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
with pytest.raises(MlflowException, match="Active workspace is required"):
_get_workspace_scoped_repo_path_if_enabled("some/path")
def test_get_artifact_handler_applies_workspace_scoping(monkeypatch):
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
monkeypatch.setenv(SERVE_ARTIFACTS_ENV_VAR, "true")
monkeypatch.setenv(ARTIFACTS_DESTINATION_ENV_VAR, "s3://bucket")
mock_run = mock.MagicMock()
mock_run.info.artifact_uri = "mlflow-artifacts:/exp1/run1/artifacts"
mock_artifact_repo = mock.MagicMock()
mock_artifact_repo.download_artifacts.return_value = "/tmp/artifact.txt"
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_artifact_repo_mlflow_artifacts") as mock_repo,
mock.patch("mlflow.server.handlers._send_artifact") as mock_send,
):
mock_store.return_value.get_run.return_value = mock_run
mock_repo.return_value = mock_artifact_repo
with WorkspaceContext("team-blue"):
with app.test_request_context(
method="GET", query_string={"run_id": "run1", "path": "model/weights.bin"}
):
get_artifact_handler()
mock_send.assert_called_once()
artifact_path = mock_send.call_args[0][1]
assert artifact_path.startswith("workspaces/team-blue/")
def test_get_artifact_handler_no_scoping_when_workspaces_disabled(monkeypatch):
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "false")
monkeypatch.setenv(SERVE_ARTIFACTS_ENV_VAR, "true")
monkeypatch.setenv(ARTIFACTS_DESTINATION_ENV_VAR, "s3://bucket")
mock_run = mock.MagicMock()
mock_run.info.artifact_uri = "mlflow-artifacts:/exp1/run1/artifacts"
mock_artifact_repo = mock.MagicMock()
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_artifact_repo_mlflow_artifacts") as mock_repo,
mock.patch("mlflow.server.handlers._send_artifact") as mock_send,
):
mock_store.return_value.get_run.return_value = mock_run
mock_repo.return_value = mock_artifact_repo
with app.test_request_context(
method="GET", query_string={"run_id": "run1", "path": "model/weights.bin"}
):
get_artifact_handler()
mock_send.assert_called_once()
artifact_path = mock_send.call_args[0][1]
assert not artifact_path.startswith("workspaces/")
def test_get_model_version_artifact_handler_applies_workspace_scoping(monkeypatch):
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
monkeypatch.setenv(SERVE_ARTIFACTS_ENV_VAR, "true")
monkeypatch.setenv(ARTIFACTS_DESTINATION_ENV_VAR, "s3://bucket")
mock_artifact_repo = mock.MagicMock()
with (
mock.patch("mlflow.server.handlers._get_model_registry_store") as mock_store,
mock.patch("mlflow.server.handlers._get_artifact_repo_mlflow_artifacts") as mock_repo,
mock.patch("mlflow.server.handlers._send_artifact") as mock_send,
):
mock_store.return_value.get_model_version_download_uri.return_value = (
"mlflow-artifacts:/models/MyModel/1"
)
mock_repo.return_value = mock_artifact_repo
with WorkspaceContext("team-red"):
with app.test_request_context(
method="GET", query_string={"name": "MyModel", "version": "1", "path": "model.pkl"}
):
get_model_version_artifact_handler()
mock_send.assert_called_once()
artifact_path = mock_send.call_args[0][1]
assert artifact_path.startswith("workspaces/team-red/")
def test_get_logged_model_artifact_handler_applies_workspace_scoping(monkeypatch):
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
monkeypatch.setenv(SERVE_ARTIFACTS_ENV_VAR, "true")
monkeypatch.setenv(ARTIFACTS_DESTINATION_ENV_VAR, "s3://bucket")
mock_logged_model = mock.MagicMock()
mock_logged_model.artifact_location = "mlflow-artifacts:/exp1/run1/artifacts/model"
mock_artifact_repo = mock.MagicMock()
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_artifact_repo_mlflow_artifacts") as mock_repo,
mock.patch("mlflow.server.handlers._send_artifact") as mock_send,
):
mock_store.return_value.get_logged_model.return_value = mock_logged_model
mock_repo.return_value = mock_artifact_repo
with WorkspaceContext("team-green"):
with app.test_request_context(
method="GET", query_string={"artifact_file_path": "MLmodel"}
):
get_logged_model_artifact_handler("model123")
mock_send.assert_called_once()
artifact_path = mock_send.call_args[0][1]
assert artifact_path.startswith("workspaces/team-green/")
def test_upload_artifact_handler_applies_workspace_scoping(monkeypatch):
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
monkeypatch.setenv(SERVE_ARTIFACTS_ENV_VAR, "true")
monkeypatch.setenv(ARTIFACTS_DESTINATION_ENV_VAR, "s3://bucket")
mock_run = mock.MagicMock()
mock_run.info.artifact_uri = "mlflow-artifacts:/exp1/run1/artifacts"
mock_run.info.experiment_id = "exp1"
mock_run.info.run_id = "run1"
mock_artifact_repo = mock.MagicMock()
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_artifact_repo_mlflow_artifacts") as mock_repo,
):
mock_store.return_value.get_run.return_value = mock_run
mock_repo.return_value = mock_artifact_repo
with WorkspaceContext("team-purple"):
with app.test_request_context(
method="POST",
query_string={"run_uuid": "run1", "path": "output.txt"},
data=b"test data",
):
upload_artifact_handler()
mock_artifact_repo.log_artifact.assert_called_once()
logged_path = mock_artifact_repo.log_artifact.call_args[0][1]
assert logged_path.startswith("workspaces/team-purple/")
def test_list_artifacts_for_proxied_run_artifact_root_applies_workspace_scoping(monkeypatch):
from mlflow.store.artifact.artifact_repo import ArtifactRepository
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
monkeypatch.setenv(SERVE_ARTIFACTS_ENV_VAR, "true")
monkeypatch.setenv(ARTIFACTS_DESTINATION_ENV_VAR, "s3://bucket")
mock_artifact_repo = mock.MagicMock(spec=ArtifactRepository)
mock_artifact_repo.list_artifacts.return_value = []
with (
mock.patch("mlflow.server.handlers._get_artifact_repo_mlflow_artifacts") as mock_repo,
WorkspaceContext("team-orange"),
):
mock_repo.return_value = mock_artifact_repo
_list_artifacts_for_proxied_run_artifact_root(
proxied_artifact_root="mlflow-artifacts:/exp1/run1/artifacts",
relative_path="model",
)
mock_artifact_repo.list_artifacts.assert_called_once()
listed_path = mock_artifact_repo.list_artifacts.call_args[0][0]
assert listed_path.startswith("workspaces/team-orange/")
# ==================== Budget Window Tests ====================
def _make_budget_policy(
budget_policy_id="bp-test",
budget_amount=100.0,
duration=None,
):
return GatewayBudgetPolicy(
budget_policy_id=budget_policy_id,
budget_unit=BudgetUnit.USD,
budget_amount=budget_amount,
duration=duration or BudgetDuration(unit=BudgetDurationUnit.DAYS, value=1),
target_scope=BudgetTargetScope.GLOBAL,
budget_action=BudgetAction.ALERT,
created_at=0,
last_updated_at=0,
)
def test_list_budget_windows_empty():
with (
app.test_client() as c,
mock.patch("mlflow.server.handlers.get_budget_tracker") as mock_tracker,
mock.patch("mlflow.server.handlers.maybe_refresh_budget_policies"),
):
mock_tracker.return_value.get_all_windows.return_value = []
response = c.get("/ajax-api/3.0/mlflow/gateway/budgets/windows")
assert response.status_code == 200
assert response.json.get("windows", []) == []
def test_list_budget_windows_returns_window_data():
tracker = InMemoryBudgetTracker()
policy = _make_budget_policy(budget_policy_id="bp-1", budget_amount=50.0)
tracker.refresh_policies([policy])
tracker.record_cost(12.5)
with (
app.test_client() as c,
mock.patch("mlflow.server.handlers.get_budget_tracker", return_value=tracker),
mock.patch("mlflow.server.handlers.maybe_refresh_budget_policies"),
):
response = c.get("/ajax-api/3.0/mlflow/gateway/budgets/windows")
assert response.status_code == 200
data = response.json
assert len(data["windows"]) == 1
window = data["windows"][0]
assert window["budget_policy_id"] == "bp-1"
assert window["current_spend"] == 12.5
min_ms = int(datetime(2000, 1, 1, tzinfo=timezone.utc).timestamp() * 1000)
assert window["window_start_ms"] >= min_ms
assert window["window_end_ms"] > window["window_start_ms"]
# Policy uses duration_unit=DAYS, duration_value=1 → exactly 1 day
assert window["window_end_ms"] - window["window_start_ms"] == 86_400_000
def test_list_budget_windows_multiple_policies():
tracker = InMemoryBudgetTracker()
policy1 = _make_budget_policy(budget_policy_id="bp-1", budget_amount=100.0)
policy2 = _make_budget_policy(budget_policy_id="bp-2", budget_amount=200.0)
tracker.refresh_policies([policy1, policy2])
tracker.record_cost(30.0)
with (
app.test_client() as c,
mock.patch("mlflow.server.handlers.get_budget_tracker", return_value=tracker),
mock.patch("mlflow.server.handlers.maybe_refresh_budget_policies"),
):
response = c.get("/ajax-api/3.0/mlflow/gateway/budgets/windows")
assert response.status_code == 200
data = response.json
policy_ids = {w["budget_policy_id"] for w in data["windows"]}
assert policy_ids == {"bp-1", "bp-2"}
windows_by_id = {w["budget_policy_id"]: w for w in data["windows"]}
assert windows_by_id["bp-1"]["current_spend"] == 30.0
assert windows_by_id["bp-2"]["current_spend"] == 30.0
def test_list_budget_windows_zero_spend():
tracker = InMemoryBudgetTracker()
policy = _make_budget_policy(budget_amount=100.0)
tracker.refresh_policies([policy])
with (
app.test_client() as c,
mock.patch("mlflow.server.handlers.get_budget_tracker", return_value=tracker),
mock.patch("mlflow.server.handlers.maybe_refresh_budget_policies"),
):
response = c.get("/ajax-api/3.0/mlflow/gateway/budgets/windows")
assert response.status_code == 200
window = response.json["windows"][0]
assert window["budget_policy_id"] == "bp-test"
assert window["current_spend"] == 0.0
def test_list_budget_windows_workspace_scoped_filters_workspace_policies():
tracker = InMemoryBudgetTracker()
global_policy = GatewayBudgetPolicy(
budget_policy_id="bp-global",
budget_unit=BudgetUnit.USD,
budget_amount=100.0,
duration=BudgetDuration(unit=BudgetDurationUnit.DAYS, value=1),
target_scope=BudgetTargetScope.GLOBAL,
budget_action=BudgetAction.ALERT,
created_at=0,
last_updated_at=0,
)
ws_policy = GatewayBudgetPolicy(
budget_policy_id="bp-ws",
budget_unit=BudgetUnit.USD,
budget_amount=50.0,
duration=BudgetDuration(unit=BudgetDurationUnit.DAYS, value=1),
target_scope=BudgetTargetScope.WORKSPACE,
budget_action=BudgetAction.ALERT,
created_at=0,
last_updated_at=0,
workspace="team-a",
)
other_ws_policy = GatewayBudgetPolicy(
budget_policy_id="bp-other",
budget_unit=BudgetUnit.USD,
budget_amount=75.0,
duration=BudgetDuration(unit=BudgetDurationUnit.DAYS, value=1),
target_scope=BudgetTargetScope.WORKSPACE,
budget_action=BudgetAction.ALERT,
created_at=0,
last_updated_at=0,
workspace="team-b",
)
tracker.refresh_policies([global_policy, ws_policy, other_ws_policy])
with (
app.test_client() as c,
mock.patch("mlflow.server.handlers.get_budget_tracker", return_value=tracker),
mock.patch("mlflow.server.handlers.maybe_refresh_budget_policies"),
WorkspaceContext("team-a"),
):
response = c.get("/ajax-api/3.0/mlflow/gateway/budgets/windows")
assert response.status_code == 200
policy_ids = {w["budget_policy_id"] for w in response.json["windows"]}
assert policy_ids == {"bp-global", "bp-ws"}
def test_list_budget_windows_no_workspace_returns_all():
tracker = InMemoryBudgetTracker()
global_policy = GatewayBudgetPolicy(
budget_policy_id="bp-global",
budget_unit=BudgetUnit.USD,
budget_amount=100.0,
duration=BudgetDuration(unit=BudgetDurationUnit.DAYS, value=1),
target_scope=BudgetTargetScope.GLOBAL,
budget_action=BudgetAction.ALERT,
created_at=0,
last_updated_at=0,
)
ws_policy = GatewayBudgetPolicy(
budget_policy_id="bp-ws",
budget_unit=BudgetUnit.USD,
budget_amount=50.0,
duration=BudgetDuration(unit=BudgetDurationUnit.DAYS, value=1),
target_scope=BudgetTargetScope.WORKSPACE,
budget_action=BudgetAction.ALERT,
created_at=0,
last_updated_at=0,
workspace="team-a",
)
tracker.refresh_policies([global_policy, ws_policy])
with (
app.test_client() as c,
mock.patch("mlflow.server.handlers.get_budget_tracker", return_value=tracker),
mock.patch("mlflow.server.handlers.maybe_refresh_budget_policies"),
):
response = c.get("/ajax-api/3.0/mlflow/gateway/budgets/windows")
assert response.status_code == 200
policy_ids = {w["budget_policy_id"] for w in response.json["windows"]}
assert policy_ids == {"bp-global", "bp-ws"}
def test_create_issue_with_all_fields():
request_message = CreateIssue()
request_message.experiment_id = "exp-123"
request_message.name = "High latency"
request_message.description = "API calls are taking too long"
request_message.status = "pending"
request_message.source_run_id = "run-123"
request_message.root_causes.extend(["Database query inefficiency", "Network latency"])
request_message.categories.extend(["performance", "database"])
request_message.severity = IssueSeverity.HIGH.value
request_message.created_by = "user@example.com"
issue = Issue(
issue_id="iss-123",
experiment_id="exp-123",
name="High latency",
description="API calls are taking too long",
status=IssueStatus.PENDING,
source_run_id="run-123",
root_causes=["Database query inefficiency", "Network latency"],
categories=["performance", "database"],
severity=IssueSeverity.HIGH,
created_timestamp=1234567890,
last_updated_timestamp=1234567890,
created_by="user@example.com",
)
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_request_message", return_value=request_message),
):
mock_store.return_value.create_issue.return_value = issue
response = _create_issue()
mock_store.return_value.create_issue.assert_called_once()
call_kwargs = mock_store.return_value.create_issue.call_args[1]
assert call_kwargs["experiment_id"] == "exp-123"
assert call_kwargs["name"] == "High latency"
assert call_kwargs["description"] == "API calls are taking too long"
assert call_kwargs["status"] == IssueStatus.PENDING
assert call_kwargs["source_run_id"] == "run-123"
assert call_kwargs["root_causes"] == ["Database query inefficiency", "Network latency"]
assert call_kwargs["categories"] == ["performance", "database"]
assert call_kwargs["severity"] == IssueSeverity.HIGH.value
assert call_kwargs["created_by"] == "user@example.com"
json_response = json.loads(response.get_data())
assert json_response["issue"]["issue_id"] == "iss-123"
assert json_response["issue"]["root_causes"] == [
"Database query inefficiency",
"Network latency",
]
assert json_response["issue"]["categories"] == ["performance", "database"]
def test_create_issue_without_optional_fields():
request_message = CreateIssue()
request_message.experiment_id = "exp-456"
request_message.name = "Error handling issue"
request_message.description = "Errors are not being caught properly"
issue = Issue(
issue_id="iss-456",
experiment_id="exp-456",
name="Error handling issue",
description="Errors are not being caught properly",
status=IssueStatus.PENDING,
created_timestamp=1234567890,
last_updated_timestamp=1234567890,
)
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_request_message", return_value=request_message),
):
mock_store.return_value.create_issue.return_value = issue
response = _create_issue()
mock_store.return_value.create_issue.assert_called_once()
call_kwargs = mock_store.return_value.create_issue.call_args[1]
assert call_kwargs["source_run_id"] is None
assert call_kwargs["root_causes"] is None
assert "severity" not in call_kwargs
json_response = json.loads(response.get_data())
assert json_response["issue"]["issue_id"] == "iss-456"
def test_create_issue_with_default_status():
request_message = CreateIssue()
request_message.experiment_id = "exp-789"
request_message.name = "Test issue"
request_message.description = "Test description"
issue = Issue(
issue_id="iss-789",
experiment_id="exp-789",
name="Test issue",
description="Test description",
status=IssueStatus.PENDING,
created_timestamp=1234567890,
last_updated_timestamp=1234567890,
)
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_request_message", return_value=request_message),
):
mock_store.return_value.create_issue.return_value = issue
_create_issue()
call_kwargs = mock_store.return_value.create_issue.call_args[1]
# Status should not be in kwargs when not provided (store uses default)
assert "status" not in call_kwargs
def test_get_issue():
issue = Issue(
issue_id="iss-get-123",
experiment_id="exp-123",
name="Test issue",
description="Test description",
status=IssueStatus.RESOLVED,
severity=IssueSeverity.HIGH,
root_causes=["Root cause 1"],
created_timestamp=1234567890,
last_updated_timestamp=1234567890,
)
with mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store:
mock_store.return_value.get_issue.return_value = issue
with app.test_request_context():
response = _get_issue("iss-get-123")
mock_store.return_value.get_issue.assert_called_once_with("iss-get-123")
json_response = json.loads(response.get_data())
assert json_response["issue"]["issue_id"] == "iss-get-123"
assert json_response["issue"]["name"] == "Test issue"
assert json_response["issue"]["severity"] == "high"
assert json_response["issue"]["root_causes"] == ["Root cause 1"]
def test_get_issue_not_found():
with mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store:
mock_store.return_value.get_issue.side_effect = MlflowException(
"Issue not found", error_code=RESOURCE_DOES_NOT_EXIST
)
with app.test_request_context():
response = _get_issue("nonexistent-id")
# The @catch_mlflow_exception decorator catches and returns error as JSON
assert response.status_code == 404
json_response = json.loads(response.get_data())
assert json_response["error_code"] == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
assert "Issue not found" in json_response["message"]
def test_update_issue():
request_message = UpdateIssue()
request_message.issue_id = "iss-update-123"
request_message.name = "Updated issue name"
request_message.description = "Updated description"
request_message.status = "resolved"
request_message.severity = "medium"
updated_issue = Issue(
issue_id="iss-update-123",
experiment_id="exp-123",
name="Updated issue name",
description="Updated description",
status=IssueStatus.RESOLVED,
severity=IssueSeverity.MEDIUM,
created_timestamp=1234567890,
last_updated_timestamp=1234567900,
)
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_request_message", return_value=request_message),
):
mock_store.return_value.update_issue.return_value = updated_issue
response = _update_issue("iss-update-123")
mock_store.return_value.update_issue.assert_called_once()
call_kwargs = mock_store.return_value.update_issue.call_args[1]
assert call_kwargs["issue_id"] == "iss-update-123"
assert call_kwargs["name"] == "Updated issue name"
assert call_kwargs["description"] == "Updated description"
assert call_kwargs["status"] == IssueStatus.RESOLVED
assert call_kwargs["severity"] == IssueSeverity.MEDIUM.value
json_response = json.loads(response.get_data())
assert json_response["issue"]["issue_id"] == "iss-update-123"
assert json_response["issue"]["name"] == "Updated issue name"
assert json_response["issue"]["severity"] == "medium"
def test_search_issues_all():
request_message = SearchIssues()
issues = [
Issue(
issue_id="iss-1",
experiment_id="exp-1",
name="Issue 1",
description="Description 1",
status=IssueStatus.PENDING,
created_timestamp=1234567890,
last_updated_timestamp=1234567890,
),
Issue(
issue_id="iss-2",
experiment_id="exp-1",
name="Issue 2",
description="Description 2",
status=IssueStatus.RESOLVED,
created_timestamp=1234567891,
last_updated_timestamp=1234567891,
),
]
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_request_message", return_value=request_message),
):
mock_store.return_value.search_issues.return_value = PagedList(issues, token="next-token")
response = _search_issues()
mock_store.return_value.search_issues.assert_called_once()
call_kwargs = mock_store.return_value.search_issues.call_args[1]
# max_results not specified in request, so it's not passed to store
# The store will use its own default parameter value (SEARCH_ISSUES_DEFAULT_MAX_RESULTS)
assert "max_results" not in call_kwargs
assert call_kwargs["experiment_id"] is None
assert call_kwargs["filter_string"] is None
json_response = json.loads(response.get_data())
assert len(json_response["issues"]) == 2
assert json_response["issues"][0]["issue_id"] == "iss-1"
assert json_response["issues"][1]["issue_id"] == "iss-2"
assert json_response["next_page_token"] == "next-token"
def test_search_issues_with_filters():
request_message = SearchIssues()
request_message.experiment_id = "exp-specific"
request_message.filter_string = "status = 'resolved' AND source_run_id = 'run-specific'"
request_message.max_results = 50
issues = [
Issue(
issue_id="iss-filtered",
experiment_id="exp-specific",
name="Filtered issue",
description="Description",
status=IssueStatus.RESOLVED,
source_run_id="run-specific",
created_timestamp=1234567890,
last_updated_timestamp=1234567890,
),
]
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_request_message", return_value=request_message),
):
mock_store.return_value.search_issues.return_value = PagedList(issues, token=None)
response = _search_issues()
call_kwargs = mock_store.return_value.search_issues.call_args[1]
assert call_kwargs["experiment_id"] == "exp-specific"
assert (
call_kwargs["filter_string"] == "status = 'resolved' AND source_run_id = 'run-specific'"
)
assert call_kwargs["max_results"] == 50
json_response = json.loads(response.get_data())
assert len(json_response["issues"]) == 1
assert json_response["issues"][0]["issue_id"] == "iss-filtered"
assert json_response["next_page_token"] == ""
def test_search_issues_with_pagination():
request_message = SearchIssues()
request_message.max_results = 10
request_message.page_token = "token-123"
issues = [
Issue(
issue_id=f"iss-{i}",
experiment_id="exp-1",
name=f"Issue {i}",
description=f"Description {i}",
status=IssueStatus.PENDING,
created_timestamp=1234567890 + i,
last_updated_timestamp=1234567890 + i,
)
for i in range(10)
]
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_request_message", return_value=request_message),
):
mock_store.return_value.search_issues.return_value = PagedList(issues, token="token-456")
response = _search_issues()
call_kwargs = mock_store.return_value.search_issues.call_args[1]
assert call_kwargs["max_results"] == 10
assert call_kwargs["page_token"] == "token-123"
json_response = json.loads(response.get_data())
assert len(json_response["issues"]) == 10
assert json_response["next_page_token"] == "token-456"
def test_search_issues_empty_results():
request_message = SearchIssues()
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_request_message", return_value=request_message),
):
mock_store.return_value.search_issues.return_value = PagedList([], token=None)
response = _search_issues()
json_response = json.loads(response.get_data())
assert len(json_response.get("issues", [])) == 0
assert json_response["next_page_token"] == ""
def test_search_issues_with_trace_count():
request_message = SearchIssues()
request_message.include_trace_count = True
issues = [
Issue(
issue_id="iss-1",
experiment_id="exp-1",
name="Issue with traces",
description="Has 2 traces",
status=IssueStatus.PENDING,
created_timestamp=1234567890,
last_updated_timestamp=1234567890,
trace_count=2,
),
Issue(
issue_id="iss-2",
experiment_id="exp-1",
name="Issue without traces",
description="Has no traces",
status=IssueStatus.PENDING,
created_timestamp=1234567891,
last_updated_timestamp=1234567891,
trace_count=0,
),
]
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_request_message", return_value=request_message),
):
mock_store.return_value.search_issues.return_value = PagedList(issues, token=None)
response = _search_issues()
call_kwargs = mock_store.return_value.search_issues.call_args[1]
assert call_kwargs["include_trace_count"] is True
json_response = json.loads(response.get_data())
assert len(json_response["issues"]) == 2
assert json_response["issues"][0]["trace_count"] == 2
assert json_response["issues"][1]["trace_count"] == 0
def test_create_issue_with_empty_lists():
request_message = CreateIssue()
request_message.experiment_id = "exp-123"
request_message.name = "Test issue"
request_message.description = "Test description"
issue = Issue(
issue_id="iss-empty-lists",
experiment_id="exp-123",
name="Test issue",
description="Test description",
status=IssueStatus.PENDING,
created_timestamp=1234567890,
last_updated_timestamp=1234567890,
)
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_request_message", return_value=request_message),
):
mock_store.return_value.create_issue.return_value = issue
_create_issue()
call_kwargs = mock_store.return_value.create_issue.call_args[1]
# Empty lists should be passed as None
assert call_kwargs["root_causes"] is None
def test_invoke_issue_detection_handler_success(monkeypatch):
monkeypatch.setenv("MLFLOW_SERVER_ENABLE_JOB_EXECUTION", "true")
mock_job = JobEntity(
job_id="job-123",
creation_time=1234567890000,
job_name="invoke_issue_detection",
params='{"experiment_id": "exp-123"}',
timeout=None,
status=JobStatus.PENDING,
result=None,
retry_count=0,
last_update_time=1234567890000,
status_details=None,
)
mock_run_info = mock.MagicMock()
mock_run_info.run_id = "run-123"
mock_run = mock.MagicMock()
mock_run.info = mock_run_info
request_json = {
"experiment_id": "exp-123",
"trace_ids": ["trace-1", "trace-2"],
"categories": ["correctness", "safety"],
"provider": "openai",
"model": "gpt-4o",
"secret_id": "secret-123",
}
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch(
"mlflow.genai.discovery.job._fetch_provider_credentials",
return_value={"OPENAI_API_KEY": "test-key"},
) as mock_fetch_creds,
mock.patch("mlflow.server.jobs.submit_job", return_value=mock_job) as mock_submit_job,
mock.patch("mlflow.start_run", return_value=mock_run),
mock.patch("mlflow.set_tag"),
mock.patch("mlflow.end_run"),
app.test_client() as c,
):
resp = c.post(
"/ajax-api/3.0/mlflow/issues/invoke",
json=request_json,
)
assert resp.status_code == 200
json_response = resp.get_json()
assert json_response["job_id"] == "job-123"
assert json_response["run_id"] == "run-123"
mock_fetch_creds.assert_called_once_with(mock_store.return_value, "openai", "secret-123")
mock_submit_job.assert_called_once()
call_kwargs = mock_submit_job.call_args.kwargs
assert call_kwargs["params"]["experiment_id"] == "exp-123"
assert call_kwargs["params"]["trace_ids"] == ["trace-1", "trace-2"]
assert call_kwargs["params"]["categories"] == ["correctness", "safety"]
assert call_kwargs["params"]["model"] == "openai:/gpt-4o"
assert call_kwargs["extra_envs"] == {"OPENAI_API_KEY": "test-key"}
def test_invoke_issue_detection_handler_with_endpoint(monkeypatch):
monkeypatch.setenv("MLFLOW_SERVER_ENABLE_JOB_EXECUTION", "true")
mock_job = JobEntity(
job_id="job-456",
creation_time=1234567890000,
job_name="invoke_issue_detection",
params='{"experiment_id": "exp-123"}',
timeout=None,
status=JobStatus.PENDING,
result=None,
retry_count=0,
last_update_time=1234567890000,
status_details=None,
)
mock_run_info = mock.MagicMock()
mock_run_info.run_id = "run-456"
mock_run = mock.MagicMock()
mock_run.info = mock_run_info
request_json = {
"experiment_id": "exp-123",
"trace_ids": ["trace-1"],
"categories": ["correctness"],
"provider": "openai",
"endpoint_name": "my-endpoint",
"secret_id": "secret-123",
}
with (
mock.patch(
"mlflow.genai.discovery.job._fetch_provider_credentials",
return_value={"OPENAI_API_KEY": "test-key"},
),
mock.patch("mlflow.server.jobs.submit_job", return_value=mock_job) as mock_submit_job,
mock.patch("mlflow.start_run", return_value=mock_run),
mock.patch("mlflow.set_tag"),
mock.patch("mlflow.end_run"),
app.test_client() as c,
):
resp = c.post(
"/ajax-api/3.0/mlflow/issues/invoke",
json=request_json,
)
assert resp.status_code == 200
json_response = resp.get_json()
assert json_response["job_id"] == "job-456"
assert json_response["run_id"] == "run-456"
call_kwargs = mock_submit_job.call_args.kwargs
assert call_kwargs["params"]["model"] == "gateway:/my-endpoint"
def test_invoke_issue_detection_handler_missing_required_params(monkeypatch):
monkeypatch.setenv("MLFLOW_SERVER_ENABLE_JOB_EXECUTION", "true")
request_json = {
"experiment_id": "exp-123",
"trace_ids": ["trace-1"],
"categories": ["correctness"],
"provider": "openai",
# Missing both 'model' and 'endpoint_name'
"secret_id": "secret-123",
}
with (
mock.patch(
"mlflow.genai.discovery.job._fetch_provider_credentials",
return_value={"OPENAI_API_KEY": "test-key"},
),
app.test_client() as c,
):
resp = c.post(
"/ajax-api/3.0/mlflow/issues/invoke",
json=request_json,
)
assert resp.status_code == 500
json_response = resp.get_json()
assert (
"Either 'endpoint_name' or both 'provider' and 'model' must be provided"
in json_response["message"]
)
def _make_genai_evaluate_job(job_id: str = "job-genai-1") -> JobEntity:
return JobEntity(
job_id=job_id,
creation_time=1234567890000,
job_name="invoke_genai_evaluate",
params='{"experiment_id": "exp-123"}',
timeout=None,
status=JobStatus.PENDING,
result=None,
retry_count=0,
last_update_time=1234567890000,
status_details=None,
)
def test_invoke_genai_evaluate_handler_success(monkeypatch):
monkeypatch.setenv("MLFLOW_SERVER_ENABLE_JOB_EXECUTION", "true")
mock_job = _make_genai_evaluate_job()
mock_run = mock.MagicMock()
mock_run.info.run_id = "run-genai-1"
mock_client = mock.MagicMock()
mock_client.create_run.return_value = mock_run
request_json = {
"experiment_id": "exp-123",
"trace_ids": ["trace-1", "trace-2"],
"serialized_scorers": ['{"name":"my-judge"}', '{"name":"safety"}'],
}
with (
mock.patch("mlflow.server.jobs.submit_job", return_value=mock_job) as mock_submit_job,
mock.patch("mlflow.server.handlers.MlflowClient", return_value=mock_client),
app.test_client() as c,
):
resp = c.post("/ajax-api/3.0/mlflow/genai/evaluate/invoke", json=request_json)
assert resp.status_code == 200
json_response = resp.get_json()
assert json_response == {"job_id": "job-genai-1", "run_id": "run-genai-1"}
# The handler must create the run *upfront* with the right MLFLOW_RUN_TYPE tag
# so the run appears on /evaluation-runs immediately, before the job starts
# producing artifacts. We use MlflowClient (not mlflow.start_run) so the run
# is created directly in the store without touching the fluent active-run stack.
mock_client.create_run.assert_called_once_with(
experiment_id="exp-123",
tags={"mlflow.runType": "genai_evaluate"},
)
mock_submit_job.assert_called_once()
submit_kwargs = mock_submit_job.call_args.kwargs
assert submit_kwargs["params"]["trace_ids"] == ["trace-1", "trace-2"]
assert submit_kwargs["params"]["serialized_scorers"] == request_json["serialized_scorers"]
assert submit_kwargs["params"]["run_id"] == "run-genai-1"
# No basic auth on the test client -> no username propagated.
assert submit_kwargs["params"]["username"] is None
mock_client.set_tag.assert_called_once_with(
"run-genai-1", "mlflow.genaiEvaluate.jobId", "job-genai-1"
)
mock_client.set_terminated.assert_not_called()
def test_invoke_genai_evaluate_handler_rejects_empty_trace_ids(monkeypatch):
monkeypatch.setenv("MLFLOW_SERVER_ENABLE_JOB_EXECUTION", "true")
mock_client = mock.MagicMock()
request_json = {
"experiment_id": "exp-123",
"trace_ids": [],
"serialized_scorers": ['{"name":"my-judge"}'],
}
with (
mock.patch("mlflow.server.handlers.MlflowClient", return_value=mock_client),
mock.patch("mlflow.server.jobs.submit_job") as mock_submit_job,
app.test_client() as c,
):
resp = c.post("/ajax-api/3.0/mlflow/genai/evaluate/invoke", json=request_json)
assert resp.status_code == 400
assert "Please select at least one trace to evaluate." in resp.get_json()["message"]
# Guard against accidentally creating a run for an invalid request.
mock_client.create_run.assert_not_called()
mock_submit_job.assert_not_called()
def test_invoke_genai_evaluate_handler_rejects_empty_serialized_scorers(monkeypatch):
monkeypatch.setenv("MLFLOW_SERVER_ENABLE_JOB_EXECUTION", "true")
mock_client = mock.MagicMock()
request_json = {
"experiment_id": "exp-123",
"trace_ids": ["trace-1"],
"serialized_scorers": [],
}
with (
mock.patch("mlflow.server.handlers.MlflowClient", return_value=mock_client),
mock.patch("mlflow.server.jobs.submit_job") as mock_submit_job,
app.test_client() as c,
):
resp = c.post("/ajax-api/3.0/mlflow/genai/evaluate/invoke", json=request_json)
assert resp.status_code == 400
assert "Please select at least one judge." in resp.get_json()["message"]
mock_client.create_run.assert_not_called()
mock_submit_job.assert_not_called()
def test_invoke_genai_evaluate_handler_missing_required_fields(monkeypatch):
monkeypatch.setenv("MLFLOW_SERVER_ENABLE_JOB_EXECUTION", "true")
# Each call drops one of the required fields; the schema validator
# should reject before we ever create a run.
base = {
"experiment_id": "exp-123",
"trace_ids": ["trace-1"],
"serialized_scorers": ['{"name":"my-judge"}'],
}
for missing in ("experiment_id", "trace_ids", "serialized_scorers"):
request_json = {k: v for k, v in base.items() if k != missing}
mock_client = mock.MagicMock()
with (
mock.patch("mlflow.server.handlers.MlflowClient", return_value=mock_client),
mock.patch("mlflow.server.jobs.submit_job") as mock_submit_job,
app.test_client() as c,
):
resp = c.post("/ajax-api/3.0/mlflow/genai/evaluate/invoke", json=request_json)
assert resp.status_code == 400, f"missing={missing}: {resp.get_json()}"
mock_client.create_run.assert_not_called()
mock_submit_job.assert_not_called()
def test_invoke_genai_evaluate_handler_propagates_basic_auth_username(monkeypatch):
"""Username comes from HTTP Basic auth and feeds the job's gateway-auth
path so judge LLM calls are made *as* the user.
"""
monkeypatch.setenv("MLFLOW_SERVER_ENABLE_JOB_EXECUTION", "true")
mock_job = _make_genai_evaluate_job("job-auth")
mock_run = mock.MagicMock()
mock_run.info.run_id = "run-auth"
mock_client = mock.MagicMock()
mock_client.create_run.return_value = mock_run
request_json = {
"experiment_id": "exp-123",
"trace_ids": ["trace-1"],
"serialized_scorers": ['{"name":"my-judge"}'],
}
with (
mock.patch("mlflow.server.jobs.submit_job", return_value=mock_job) as mock_submit_job,
mock.patch("mlflow.server.handlers.MlflowClient", return_value=mock_client),
app.test_client() as c,
):
# Encoded form of "alice:hunter2"
resp = c.post(
"/ajax-api/3.0/mlflow/genai/evaluate/invoke",
json=request_json,
headers={"Authorization": "Basic YWxpY2U6aHVudGVyMg=="},
)
assert resp.status_code == 200
assert mock_submit_job.call_args.kwargs["params"]["username"] == "alice"
def test_invoke_genai_evaluate_handler_marks_run_failed_when_submit_job_raises(monkeypatch):
"""If submit_job raises after the run is created, the handler must flip the
run to FAILED itself — otherwise it'd be stuck in RUNNING forever because the
worker that would normally do that transition was never enqueued.
"""
monkeypatch.setenv("MLFLOW_SERVER_ENABLE_JOB_EXECUTION", "true")
mock_run = mock.MagicMock()
mock_run.info.run_id = "run-fail"
mock_client = mock.MagicMock()
mock_client.create_run.return_value = mock_run
request_json = {
"experiment_id": "exp-123",
"trace_ids": ["trace-1"],
"serialized_scorers": ['{"name":"my-judge"}'],
}
submit_error = MlflowException(
"Mlflow server job execution feature is not enabled.",
error_code=INVALID_PARAMETER_VALUE,
)
with (
mock.patch("mlflow.server.jobs.submit_job", side_effect=submit_error),
mock.patch("mlflow.server.handlers.MlflowClient", return_value=mock_client),
app.test_client() as c,
):
resp = c.post("/ajax-api/3.0/mlflow/genai/evaluate/invoke", json=request_json)
# @catch_mlflow_exception turns the re-raised MlflowException into a 4xx.
assert resp.status_code != 200
assert "job execution feature is not enabled" in resp.get_json()["message"]
mock_client.set_terminated.assert_called_once_with("run-fail", "FAILED")
# The job-id tag must not have been written — the job never existed.
mock_client.set_tag.assert_not_called()
def test_invoke_genai_evaluate_handler_marks_run_failed_when_set_tag_raises(monkeypatch):
"""The same try/except must also cover the post-submit set_tag call. If the
tag write fails (e.g. transient store error) we'd otherwise leave the run in
RUNNING because nothing else writes a terminal status from the handler.
"""
monkeypatch.setenv("MLFLOW_SERVER_ENABLE_JOB_EXECUTION", "true")
mock_job = _make_genai_evaluate_job("job-tag-fail")
mock_run = mock.MagicMock()
mock_run.info.run_id = "run-tag-fail"
mock_client = mock.MagicMock()
mock_client.create_run.return_value = mock_run
mock_client.set_tag.side_effect = RuntimeError("store unavailable")
request_json = {
"experiment_id": "exp-123",
"trace_ids": ["trace-1"],
"serialized_scorers": ['{"name":"my-judge"}'],
}
with (
mock.patch("mlflow.server.jobs.submit_job", return_value=mock_job),
mock.patch("mlflow.server.handlers.MlflowClient", return_value=mock_client),
app.test_client() as c,
):
resp = c.post("/ajax-api/3.0/mlflow/genai/evaluate/invoke", json=request_json)
assert resp.status_code != 200
mock_client.set_terminated.assert_called_once_with("run-tag-fail", "FAILED")
def test_get_job_success(mock_job_store):
mock_job = JobEntity(
job_id="job-123",
creation_time=1234567890000,
job_name="invoke_issue_detection",
params='{"experiment_id": "exp-123"}',
timeout=None,
status=JobStatus.SUCCEEDED,
result='{"summary": "Found 3 issues", "issues": 3, "total_traces_analyzed": 10}',
retry_count=0,
last_update_time=1234567900000,
status_details=None,
)
with (
mock.patch("mlflow.server.jobs.get_job", return_value=mock_job),
app.test_client() as c,
):
resp = c.get("/ajax-api/3.0/mlflow/jobs/job-123")
assert resp.status_code == 200
json_response = resp.get_json()
assert json_response["status"] == "SUCCEEDED"
assert json_response["result"]["summary"] == "Found 3 issues"
assert json_response["result"]["issues"] == 3
assert json_response["result"]["total_traces_analyzed"] == 10
assert json_response["status_details"] is None
def test_get_job_pending(mock_job_store):
mock_job = JobEntity(
job_id="job-pending",
creation_time=1234567890000,
job_name="invoke_issue_detection",
params='{"experiment_id": "exp-123"}',
timeout=None,
status=JobStatus.PENDING,
result=None,
retry_count=0,
last_update_time=1234567890000,
status_details=None,
)
with (
mock.patch("mlflow.server.jobs.get_job", return_value=mock_job),
app.test_client() as c,
):
resp = c.get("/ajax-api/3.0/mlflow/jobs/job-pending")
assert resp.status_code == 200
json_response = resp.get_json()
assert json_response["status"] == "PENDING"
assert json_response["result"] is None
assert json_response["status_details"] is None
def test_cancel_job_success(mock_job_store):
mock_job = JobEntity(
job_id="job-123",
creation_time=1234567890000,
job_name="invoke_issue_detection",
params='{"experiment_id": "exp-123"}',
timeout=None,
status=JobStatus.CANCELED,
result=None,
retry_count=0,
last_update_time=1234567900000,
status_details=None,
)
with (
mock.patch("mlflow.server.jobs.cancel_job", return_value=mock_job) as mock_cancel,
app.test_client() as c,
):
resp = c.patch("/ajax-api/3.0/mlflow/jobs/cancel/job-123")
assert resp.status_code == 200
json_response = resp.get_json()
assert json_response["status"] == "CANCELED"
mock_cancel.assert_called_once_with("job-123")
def test_get_rest_path_respects_static_prefix(monkeypatch):
# Without prefix, both return bare paths
assert _get_rest_path("/mlflow/experiments/search") == "/api/2.0/mlflow/experiments/search"
assert _get_ajax_path("/mlflow/experiments/search") == "/ajax-api/2.0/mlflow/experiments/search"
# With prefix, both should include the prefix
monkeypatch.setenv(STATIC_PREFIX_ENV_VAR, "/myapp")
assert (
_get_rest_path("/mlflow/experiments/search") == "/myapp/api/2.0/mlflow/experiments/search"
)
assert (
_get_ajax_path("/mlflow/experiments/search")
== "/myapp/ajax-api/2.0/mlflow/experiments/search"
)
def _review_queue_entity(**overrides):
defaults = {
"queue_id": "rq-1",
"experiment_id": "exp-1",
"name": "q",
"queue_type": ReviewQueueType.CUSTOM,
"created_by": "alice",
"creation_time_ms": 1,
"last_update_time_ms": 1,
"users": [],
"schema_ids": [],
}
defaults.update(overrides)
return ReviewQueue(**defaults)
def test_create_review_queue_stamps_owner_from_authenticated_user():
request_message = CreateReviewQueue()
request_message.experiment_id = "exp-1"
request_message.name = "q"
request_message.queue_type = ReviewQueueType.CUSTOM.to_proto()
# The client even tries to set a different owner; it must be ignored.
request_message.created_by = "spoofed"
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_request_message", return_value=request_message),
mock.patch("mlflow.server.handlers._get_request_username", return_value="alice"),
):
mock_store.return_value.create_review_queue.return_value = _review_queue_entity()
_create_review_queue()
call_kwargs = mock_store.return_value.create_review_queue.call_args[1]
assert call_kwargs["created_by"] == "alice"
def test_create_review_queue_no_owner_on_noauth():
request_message = CreateReviewQueue()
request_message.experiment_id = "exp-1"
request_message.name = "q"
request_message.queue_type = ReviewQueueType.CUSTOM.to_proto()
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_request_message", return_value=request_message),
# No auth plugin -> no request user.
mock.patch("mlflow.server.handlers._get_request_username", return_value=None),
):
mock_store.return_value.create_review_queue.return_value = _review_queue_entity(
created_by=None
)
_create_review_queue()
call_kwargs = mock_store.return_value.create_review_queue.call_args[1]
assert "created_by" not in call_kwargs
def test_update_review_queue_passes_name_and_new_owner():
request_message = UpdateReviewQueue()
request_message.queue_id = "rq-1"
request_message.name = "renamed"
request_message.new_owner = "bob"
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_request_message", return_value=request_message),
):
mock_store.return_value.update_review_queue.return_value = _review_queue_entity(
name="renamed", created_by="bob"
)
_update_review_queue()
call_kwargs = mock_store.return_value.update_review_queue.call_args[1]
assert call_kwargs["name"] == "renamed"
assert call_kwargs["new_owner"] == "bob"
# Untouched association sets stay None (proto2 presence on the singular
# fields; no update_* flag set for users/schemas).
assert call_kwargs["users"] is None
assert call_kwargs["schema_ids"] is None
def test_update_review_queue_unset_name_and_owner_pass_none():
# Only users changed; name / new_owner are unset, so HasField is False and
# the store must receive None (guards against a truthiness-vs-HasField
# regression that could, e.g., wipe the owner with an empty string).
request_message = UpdateReviewQueue()
request_message.queue_id = "rq-1"
request_message.update_users = True
request_message.users.append("bob")
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_request_message", return_value=request_message),
):
mock_store.return_value.update_review_queue.return_value = _review_queue_entity(
users=["bob"]
)
_update_review_queue()
call_kwargs = mock_store.return_value.update_review_queue.call_args[1]
assert call_kwargs["name"] is None
assert call_kwargs["new_owner"] is None
assert call_kwargs["users"] == ["bob"]
def test_get_or_create_user_queue_ignores_client_created_by():
request_message = GetOrCreateUserQueue()
request_message.experiment_id = "exp-1"
request_message.user = "alice"
request_message.created_by = "spoofed"
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_request_message", return_value=request_message),
):
mock_store.return_value.get_or_create_user_queue.return_value = _review_queue_entity(
queue_type=ReviewQueueType.USER, name="alice", created_by="alice", users=["alice"]
)
_get_or_create_user_queue()
call_kwargs = mock_store.return_value.get_or_create_user_queue.call_args[1]
assert "created_by" not in call_kwargs
assert call_kwargs["user"] == "alice"
def _review_queue_item_entity(**overrides):
defaults = {
"queue_id": "rq-1",
"item_type": ReviewItemType.TRACE,
"item_id": "tr-1",
"status": ReviewStatus.COMPLETE,
"creation_time_ms": 1,
"last_update_time_ms": 1,
"completed_by": "alice",
"completed_time_ms": 1,
}
defaults.update(overrides)
return ReviewQueueItem(**defaults)
def _set_status_request(status, completed_by=None):
request_message = SetReviewQueueItemStatus()
request_message.queue_id = "rq-1"
request_message.item_id = "tr-1"
request_message.status = status.to_proto()
if completed_by is not None:
request_message.completed_by = completed_by
return request_message
@pytest.mark.parametrize(
("username", "status", "client_completed_by", "expected_completed_by"),
[
# Auth server: the caller's identity is stamped and the client value (even
# a spoofed one) is ignored, for both terminal states.
("alice", ReviewStatus.COMPLETE, "ghost@nowhere.example", "alice"),
("alice", ReviewStatus.DECLINED, "ghost@nowhere.example", "alice"),
# Auth server reopen: attribution is cleared regardless of the client value.
("alice", ReviewStatus.PENDING, "ghost@nowhere.example", None),
# No-auth server: no identity to bind to, so the client value passes through
# verbatim (a real no-auth client sends the single `default` user).
(None, ReviewStatus.COMPLETE, "default", "default"),
(None, ReviewStatus.COMPLETE, "ghost@nowhere.example", "ghost@nowhere.example"),
],
)
def test_set_review_queue_item_status_stamps_completed_by(
username, status, client_completed_by, expected_completed_by
):
request_message = _set_status_request(status, completed_by=client_completed_by)
with (
mock.patch("mlflow.server.handlers._get_tracking_store") as mock_store,
mock.patch("mlflow.server.handlers._get_request_message", return_value=request_message),
mock.patch("mlflow.server.handlers._get_request_username", return_value=username),
):
mock_store.return_value.set_review_queue_item_status.return_value = (
_review_queue_item_entity(
status=status,
completed_by=expected_completed_by,
completed_time_ms=None if status == ReviewStatus.PENDING else 1,
)
)
_set_review_queue_item_status()
mock_store.return_value.set_review_queue_item_status.assert_called_once()
call_kwargs = mock_store.return_value.set_review_queue_item_status.call_args[1]
assert call_kwargs["completed_by"] == expected_completed_by
# Pin status pass-through too, so a regression that mangles status is caught.
assert call_kwargs["status"] == status