7866 lines
281 KiB
Python
7866 lines
281 KiB
Python
# Define all the service endpoint handlers here.
|
|
import io
|
|
import json
|
|
import logging
|
|
import os
|
|
import pathlib
|
|
import posixpath
|
|
import re
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unicodedata
|
|
import urllib
|
|
from functools import partial, wraps
|
|
from typing import Any, Callable
|
|
|
|
import requests
|
|
from cachetools import TTLCache
|
|
from flask import Request, Response, current_app, g, jsonify, request, send_file
|
|
from google.protobuf import descriptor
|
|
from google.protobuf.json_format import ParseError
|
|
from werkzeug.http import quote_header_value
|
|
|
|
import mlflow
|
|
from mlflow.client import MlflowClient
|
|
from mlflow.entities import (
|
|
Assessment,
|
|
DatasetInput,
|
|
Expectation,
|
|
ExperimentTag,
|
|
FallbackConfig,
|
|
FallbackStrategy,
|
|
Feedback,
|
|
FileInfo,
|
|
GatewayEndpointModelConfig,
|
|
GatewayEndpointTag,
|
|
GatewayResourceType,
|
|
InputTag,
|
|
IssueSeverity,
|
|
IssueStatus,
|
|
Metric,
|
|
Param,
|
|
RunStatus,
|
|
RunTag,
|
|
ViewType,
|
|
Workspace,
|
|
WorkspaceDeletionMode,
|
|
)
|
|
from mlflow.entities import (
|
|
RoutingStrategy as RoutingStrategyEntity,
|
|
)
|
|
from mlflow.entities.gateway_budget_policy import (
|
|
BudgetAction,
|
|
BudgetDuration,
|
|
BudgetDurationUnit,
|
|
BudgetTargetScope,
|
|
BudgetUnit,
|
|
)
|
|
from mlflow.entities.logged_model import LoggedModel
|
|
from mlflow.entities.logged_model_input import LoggedModelInput
|
|
from mlflow.entities.logged_model_output import LoggedModelOutput
|
|
from mlflow.entities.logged_model_parameter import LoggedModelParameter
|
|
from mlflow.entities.logged_model_status import LoggedModelStatus
|
|
from mlflow.entities.logged_model_tag import LoggedModelTag
|
|
from mlflow.entities.model_registry import ModelVersionTag, RegisteredModelTag
|
|
from mlflow.entities.model_registry.prompt_version import IS_PROMPT_TAG_KEY
|
|
from mlflow.entities.multipart_upload import MultipartUploadPart
|
|
from mlflow.entities.trace_info import TraceInfo
|
|
from mlflow.entities.trace_info_v2 import TraceInfoV2
|
|
from mlflow.entities.trace_metrics import MetricAggregation, MetricViewType
|
|
from mlflow.entities.trace_status import TraceStatus
|
|
from mlflow.entities.webhook import WebhookAction, WebhookEntity, WebhookEvent, WebhookStatus
|
|
from mlflow.environment_variables import (
|
|
MLFLOW_CREATE_MODEL_VERSION_SOURCE_VALIDATION_REGEX,
|
|
MLFLOW_DEPLOYMENTS_TARGET,
|
|
MLFLOW_ENABLE_WORKSPACES,
|
|
MLFLOW_PRESIGNED_DOWNLOAD_URL_TTL_SECONDS,
|
|
)
|
|
from mlflow.exceptions import (
|
|
MlflowException,
|
|
MlflowNotImplementedException,
|
|
MlflowTraceDataException,
|
|
MlflowTracingException,
|
|
_UnsupportedMultipartDownloadException,
|
|
_UnsupportedMultipartUploadException,
|
|
_UnsupportedPresignedUploadException,
|
|
)
|
|
from mlflow.gateway.budget import maybe_refresh_budget_policies
|
|
from mlflow.gateway.budget_tracker import _policy_applies, get_budget_tracker
|
|
from mlflow.gateway.utils import is_valid_endpoint_name
|
|
from mlflow.genai.label_schemas.label_schemas import LabelSchemaType, _input_from_proto
|
|
from mlflow.genai.review_queues import ReviewItemType, ReviewQueueType, ReviewStatus
|
|
from mlflow.genai.review_queues.validation import validate_item_ids_for_attach
|
|
from mlflow.genai.scorers.scorer_utils import DECORATOR_SCORER_REGISTRATION_NOT_SUPPORTED_ERROR
|
|
from mlflow.models import Model
|
|
from mlflow.prompt.constants import PROMPT_TEXT_TAG_KEY, PROMPT_TYPE_TAG_KEY
|
|
from mlflow.protos import databricks_pb2
|
|
from mlflow.protos.databricks_pb2 import (
|
|
BAD_REQUEST,
|
|
FEATURE_DISABLED,
|
|
INTERNAL_ERROR,
|
|
INVALID_PARAMETER_VALUE,
|
|
INVALID_STATE,
|
|
RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
from mlflow.protos.issues_pb2 import (
|
|
CreateIssue,
|
|
GetIssue,
|
|
SearchIssues,
|
|
UpdateIssue,
|
|
)
|
|
from mlflow.protos.jobs_pb2 import JobStatus
|
|
from mlflow.protos.label_schemas_pb2 import (
|
|
CreateLabelSchema,
|
|
DeleteLabelSchema,
|
|
GetLabelSchema,
|
|
GetLabelSchemaByName,
|
|
ListLabelSchemas,
|
|
UpdateLabelSchema,
|
|
)
|
|
from mlflow.protos.mlflow_artifacts_pb2 import (
|
|
AbortMultipartUpload,
|
|
CompleteMultipartUpload,
|
|
CreateMultipartUpload,
|
|
DeleteArtifact,
|
|
DownloadArtifact,
|
|
GetPresignedDownloadUrl,
|
|
MlflowArtifactsService,
|
|
UploadArtifact,
|
|
)
|
|
from mlflow.protos.mlflow_artifacts_pb2 import (
|
|
ListArtifacts as ListArtifactsMlflowArtifacts,
|
|
)
|
|
from mlflow.protos.model_registry_pb2 import (
|
|
CreateModelVersion,
|
|
CreateRegisteredModel,
|
|
DeleteModelVersion,
|
|
DeleteModelVersionTag,
|
|
DeleteRegisteredModel,
|
|
DeleteRegisteredModelAlias,
|
|
DeleteRegisteredModelTag,
|
|
GetLatestVersions,
|
|
GetModelVersion,
|
|
GetModelVersionByAlias,
|
|
GetModelVersionDownloadUri,
|
|
GetRegisteredModel,
|
|
ModelRegistryService,
|
|
RenameRegisteredModel,
|
|
SearchModelVersions,
|
|
SearchRegisteredModels,
|
|
SetModelVersionTag,
|
|
SetRegisteredModelAlias,
|
|
SetRegisteredModelTag,
|
|
TransitionModelVersionStage,
|
|
UpdateModelVersion,
|
|
UpdateRegisteredModel,
|
|
)
|
|
from mlflow.protos.prompt_optimization_pb2 import (
|
|
PromptOptimizationJob as PromptOptimizationJobProto,
|
|
)
|
|
from mlflow.protos.review_queues_pb2 import (
|
|
REVIEW_ITEM_TYPE_UNSPECIFIED,
|
|
REVIEW_STATUS_UNSPECIFIED,
|
|
AddItemsToReviewQueue,
|
|
CreateReviewQueue,
|
|
DeleteReviewQueue,
|
|
GetOrCreateUserQueue,
|
|
GetReviewQueue,
|
|
GetReviewQueueByName,
|
|
ListReviewQueueItems,
|
|
ListReviewQueues,
|
|
RemoveItemsFromReviewQueue,
|
|
SetReviewQueueItemStatus,
|
|
UpdateReviewQueue,
|
|
)
|
|
from mlflow.protos.service_pb2 import (
|
|
AddDatasetToExperiments,
|
|
AddGuardrailToEndpoint,
|
|
AttachModelToGatewayEndpoint,
|
|
BatchGetTraceInfos,
|
|
BatchGetTraces,
|
|
CalculateTraceFilterCorrelation,
|
|
CancelPromptOptimizationJob,
|
|
CreateAssessment,
|
|
CreateDataset,
|
|
CreateExperiment,
|
|
CreateGatewayBudgetPolicy,
|
|
CreateGatewayEndpoint,
|
|
CreateGatewayEndpointBinding,
|
|
CreateGatewayGuardrail,
|
|
CreateGatewayModelDefinition,
|
|
CreateGatewaySecret,
|
|
CreateLoggedModel,
|
|
CreatePresignedUploadUrl,
|
|
CreatePromptOptimizationJob,
|
|
CreateRun,
|
|
CreateWorkspace,
|
|
DeleteAssessment,
|
|
DeleteDataset,
|
|
DeleteDatasetRecords,
|
|
DeleteDatasetTag,
|
|
DeleteExperiment,
|
|
DeleteExperimentTag,
|
|
DeleteGatewayBudgetPolicy,
|
|
DeleteGatewayEndpoint,
|
|
DeleteGatewayEndpointBinding,
|
|
DeleteGatewayEndpointTag,
|
|
DeleteGatewayGuardrail,
|
|
DeleteGatewayModelDefinition,
|
|
DeleteGatewaySecret,
|
|
DeleteLoggedModel,
|
|
DeleteLoggedModelTag,
|
|
DeletePromptOptimizationJob,
|
|
DeleteRun,
|
|
DeleteScorer,
|
|
DeleteTag,
|
|
DeleteTraces,
|
|
DeleteTracesV3,
|
|
DeleteTraceTag,
|
|
DeleteTraceTagV3,
|
|
DeleteWorkspace,
|
|
DetachModelFromGatewayEndpoint,
|
|
EndTrace,
|
|
FinalizeLoggedModel,
|
|
GetAssessmentRequest,
|
|
GetDataset,
|
|
GetDatasetExperimentIds,
|
|
GetDatasetRecords,
|
|
GetExperiment,
|
|
GetExperimentByName,
|
|
GetGatewayBudgetPolicy,
|
|
GetGatewayEndpoint,
|
|
GetGatewayGuardrail,
|
|
GetGatewayModelDefinition,
|
|
GetGatewaySecretInfo,
|
|
GetLoggedModel,
|
|
GetMetricHistory,
|
|
GetMetricHistoryBulkInterval,
|
|
GetPromptOptimizationJob,
|
|
GetRun,
|
|
GetScorer,
|
|
GetTrace,
|
|
GetTraceInfo,
|
|
GetTraceInfoV3,
|
|
GetWorkspace,
|
|
LinkPromptsToTrace,
|
|
LinkTracesToRun,
|
|
ListArtifacts,
|
|
ListEndpointGuardrailConfigs,
|
|
ListGatewayBudgetPolicies,
|
|
ListGatewayBudgetWindows,
|
|
ListGatewayEndpointBindings,
|
|
ListGatewayEndpoints,
|
|
ListGatewayGuardrails,
|
|
ListGatewayModelDefinitions,
|
|
ListGatewaySecretInfos,
|
|
ListLoggedModelArtifacts,
|
|
ListScorers,
|
|
ListScorerVersions,
|
|
ListWorkspaces,
|
|
LogBatch,
|
|
LogInputs,
|
|
LogLoggedModelParamsRequest,
|
|
LogMetric,
|
|
LogModel,
|
|
LogOutputs,
|
|
LogParam,
|
|
MlflowService,
|
|
QueryTraceMetrics,
|
|
RegisterScorer,
|
|
RemoveDatasetFromExperiments,
|
|
RemoveGuardrailFromEndpoint,
|
|
RestoreExperiment,
|
|
RestoreRun,
|
|
SearchDatasets,
|
|
SearchEvaluationDatasets,
|
|
SearchExperiments,
|
|
SearchLoggedModels,
|
|
SearchPromptOptimizationJobs,
|
|
SearchRuns,
|
|
SearchTraces,
|
|
SearchTracesV3,
|
|
SetDatasetTags,
|
|
SetExperimentTag,
|
|
SetGatewayEndpointTag,
|
|
SetLoggedModelTags,
|
|
SetTag,
|
|
SetTraceTag,
|
|
SetTraceTagV3,
|
|
StartTrace,
|
|
StartTraceV3,
|
|
UpdateAssessment,
|
|
UpdateEndpointGuardrailConfig,
|
|
UpdateExperiment,
|
|
UpdateGatewayBudgetPolicy,
|
|
UpdateGatewayEndpoint,
|
|
UpdateGatewayModelDefinition,
|
|
UpdateGatewaySecret,
|
|
UpdateRun,
|
|
UpdateWorkspace,
|
|
UpsertDatasetRecords,
|
|
)
|
|
from mlflow.protos.service_pb2 import Trace as ProtoTrace
|
|
from mlflow.protos.webhooks_pb2 import (
|
|
CreateWebhook,
|
|
DeleteWebhook,
|
|
GetWebhook,
|
|
ListWebhooks,
|
|
TestWebhook,
|
|
UpdateWebhook,
|
|
WebhookService,
|
|
)
|
|
from mlflow.server.validation import _validate_content_type
|
|
from mlflow.server.workspace_helpers import (
|
|
_get_workspace_store,
|
|
)
|
|
from mlflow.store.artifact.artifact_repo import (
|
|
ARTIFACT_STREAM_CHUNK_SIZE,
|
|
MultipartDownloadMixin,
|
|
MultipartUploadMixin,
|
|
PresignedUploadMixin,
|
|
StreamUploadMixin,
|
|
)
|
|
from mlflow.store.artifact.artifact_repository_registry import get_artifact_repository
|
|
from mlflow.store.db.db_types import DATABASE_ENGINES
|
|
from mlflow.store.jobs.abstract_store import AbstractJobStore
|
|
from mlflow.store.model_registry.abstract_store import AbstractStore as AbstractModelRegistryStore
|
|
from mlflow.store.model_registry.rest_store import RestStore as ModelRegistryRestStore
|
|
from mlflow.store.tracking import (
|
|
MAX_RESULTS_QUERY_TRACE_METRICS,
|
|
SEARCH_MAX_RESULTS_DEFAULT,
|
|
SEARCH_MAX_RESULTS_THRESHOLD,
|
|
)
|
|
from mlflow.store.tracking.abstract_store import AbstractStore as AbstractTrackingStore
|
|
from mlflow.store.tracking.databricks_rest_store import DatabricksTracingRestStore
|
|
from mlflow.store.workspace.abstract_store import WorkspaceNameValidator
|
|
from mlflow.telemetry import get_telemetry_client
|
|
from mlflow.telemetry.installation_id import get_or_create_installation_id
|
|
from mlflow.telemetry.schemas import Record, Status
|
|
from mlflow.telemetry.utils import (
|
|
FALLBACK_UI_CONFIG,
|
|
fetch_ui_telemetry_config,
|
|
is_telemetry_disabled,
|
|
)
|
|
from mlflow.tracing.constant import SpansLocation, TraceTagKey
|
|
from mlflow.tracing.trace_archival_config import get_trace_archival_server_config
|
|
from mlflow.tracing.utils.artifact_utils import (
|
|
TRACE_DATA_FILE_NAME,
|
|
get_archive_uri_for_trace,
|
|
get_artifact_uri_for_trace,
|
|
)
|
|
from mlflow.tracking._model_registry import utils as registry_utils
|
|
from mlflow.tracking._model_registry.registry import ModelRegistryStoreRegistry
|
|
from mlflow.tracking._tracking_service import utils
|
|
from mlflow.tracking._tracking_service.registry import TrackingStoreRegistry
|
|
from mlflow.tracking.context.default_context import _get_user
|
|
from mlflow.tracking.registry import UnsupportedModelRegistryStoreURIException
|
|
from mlflow.utils import workspace_context
|
|
from mlflow.utils.crypto import CRYPTO_KEK_PASSPHRASE_ENV_VAR
|
|
from mlflow.utils.databricks_utils import get_databricks_host_creds
|
|
from mlflow.utils.file_utils import local_file_uri_to_path
|
|
from mlflow.utils.mime_type_utils import _guess_mime_type
|
|
from mlflow.utils.mlflow_tags import (
|
|
MLFLOW_GENAI_EVALUATE_JOB_ID,
|
|
MLFLOW_ISSUE_DETECTION_JOB_ID,
|
|
MLFLOW_RUN_TYPE,
|
|
MLFLOW_RUN_TYPE_GENAI_EVALUATE,
|
|
MLFLOW_RUN_TYPE_ISSUE_DETECTION,
|
|
MLFLOW_TRACE_ARCHIVAL_FAILURE,
|
|
MLFLOW_TRACE_ARCHIVE_LOCATION,
|
|
MLFLOW_TRACE_SPANS_LOCATION,
|
|
)
|
|
from mlflow.utils.promptlab_utils import _create_promptlab_run_impl
|
|
from mlflow.utils.proto_json_utils import message_to_json, parse_dict
|
|
from mlflow.utils.providers import (
|
|
get_all_providers,
|
|
get_models,
|
|
get_provider_config_response,
|
|
)
|
|
from mlflow.utils.string_utils import is_string_type
|
|
from mlflow.utils.time import get_current_time_millis
|
|
from mlflow.utils.uri import is_local_uri, validate_path_is_safe, validate_query_string
|
|
from mlflow.utils.validation import (
|
|
_validate_batch_log_api_req,
|
|
_validate_experiment_artifact_location,
|
|
_validate_experiment_artifact_location_length,
|
|
_validate_trace_archival_location,
|
|
_validate_trace_archival_retention_string,
|
|
invalid_value,
|
|
missing_value,
|
|
)
|
|
from mlflow.utils.workspace_utils import DEFAULT_WORKSPACE_NAME
|
|
from mlflow.webhooks.delivery import deliver_webhook, test_webhook
|
|
from mlflow.webhooks.types import (
|
|
ModelVersionAliasCreatedPayload,
|
|
ModelVersionAliasDeletedPayload,
|
|
ModelVersionCreatedPayload,
|
|
ModelVersionTagDeletedPayload,
|
|
ModelVersionTagSetPayload,
|
|
PromptAliasCreatedPayload,
|
|
PromptAliasDeletedPayload,
|
|
PromptCreatedPayload,
|
|
PromptTagDeletedPayload,
|
|
PromptTagSetPayload,
|
|
PromptVersionCreatedPayload,
|
|
PromptVersionTagDeletedPayload,
|
|
PromptVersionTagSetPayload,
|
|
RegisteredModelCreatedPayload,
|
|
)
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
_tracking_store = None
|
|
_model_registry_store = None
|
|
_job_store = None
|
|
_artifact_repo = None
|
|
STATIC_PREFIX_ENV_VAR = "_MLFLOW_STATIC_PREFIX"
|
|
MAX_RUNS_GET_METRIC_HISTORY_BULK = 100
|
|
MAX_RESULTS_PER_RUN = 2500
|
|
|
|
|
|
class TrackingStoreRegistryWrapper(TrackingStoreRegistry):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.register("", self._get_file_store)
|
|
self.register("file", self._get_file_store)
|
|
for scheme in DATABASE_ENGINES:
|
|
self.register(scheme, self._get_sqlalchemy_store)
|
|
# Add support for Databricks tracking store
|
|
self.register("databricks", self._get_databricks_rest_store)
|
|
self.register_entrypoints()
|
|
|
|
@classmethod
|
|
def _get_file_store(cls, store_uri, artifact_uri):
|
|
from mlflow.store.tracking.file_store import FileStore
|
|
|
|
return FileStore(store_uri, artifact_uri)
|
|
|
|
@classmethod
|
|
def _get_sqlalchemy_store(cls, store_uri, artifact_uri):
|
|
from mlflow.server.constants import READ_REPLICA_BACKEND_STORE_URI_ENV_VAR
|
|
from mlflow.store.tracking.sqlalchemy_store import SqlAlchemyStore
|
|
from mlflow.store.tracking.sqlalchemy_workspace_store import (
|
|
WorkspaceAwareSqlAlchemyStore,
|
|
)
|
|
|
|
read_db_uri = os.environ.get(READ_REPLICA_BACKEND_STORE_URI_ENV_VAR, None)
|
|
store_cls = (
|
|
WorkspaceAwareSqlAlchemyStore if MLFLOW_ENABLE_WORKSPACES.get() else SqlAlchemyStore
|
|
)
|
|
return store_cls(store_uri, artifact_uri, read_db_uri=read_db_uri)
|
|
|
|
@classmethod
|
|
def _get_databricks_rest_store(cls, store_uri, artifact_uri):
|
|
return DatabricksTracingRestStore(partial(get_databricks_host_creds, store_uri))
|
|
|
|
|
|
class ModelRegistryStoreRegistryWrapper(ModelRegistryStoreRegistry):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.register("", self._get_file_store)
|
|
self.register("file", self._get_file_store)
|
|
for scheme in DATABASE_ENGINES:
|
|
self.register(scheme, self._get_sqlalchemy_store)
|
|
# Add support for Databricks registries
|
|
self.register("databricks", self._get_databricks_rest_store)
|
|
self.register("databricks-uc", self._get_databricks_uc_rest_store)
|
|
self.register_entrypoints()
|
|
|
|
@classmethod
|
|
def _get_file_store(cls, store_uri):
|
|
from mlflow.store.model_registry.file_store import FileStore
|
|
|
|
return FileStore(store_uri)
|
|
|
|
@classmethod
|
|
def _get_sqlalchemy_store(cls, store_uri):
|
|
from mlflow.server.constants import READ_REPLICA_BACKEND_STORE_URI_ENV_VAR
|
|
from mlflow.store.model_registry.sqlalchemy_store import SqlAlchemyStore
|
|
from mlflow.store.model_registry.sqlalchemy_workspace_store import (
|
|
WorkspaceAwareSqlAlchemyStore,
|
|
)
|
|
|
|
read_db_uri = os.environ.get(READ_REPLICA_BACKEND_STORE_URI_ENV_VAR, None)
|
|
store_cls = (
|
|
WorkspaceAwareSqlAlchemyStore if MLFLOW_ENABLE_WORKSPACES.get() else SqlAlchemyStore
|
|
)
|
|
return store_cls(store_uri, read_db_uri=read_db_uri)
|
|
|
|
@classmethod
|
|
def _get_databricks_rest_store(cls, store_uri):
|
|
return ModelRegistryRestStore(partial(get_databricks_host_creds, store_uri))
|
|
|
|
@classmethod
|
|
def _get_databricks_uc_rest_store(cls, store_uri):
|
|
from mlflow.environment_variables import MLFLOW_TRACKING_URI
|
|
from mlflow.store._unity_catalog.registry.rest_store import UcModelRegistryStore
|
|
|
|
# Get tracking URI from environment or use "databricks-uc" as default
|
|
tracking_uri = MLFLOW_TRACKING_URI.get() or "databricks-uc"
|
|
return UcModelRegistryStore(store_uri, tracking_uri)
|
|
|
|
|
|
_tracking_store_registry = TrackingStoreRegistryWrapper()
|
|
_model_registry_store_registry = ModelRegistryStoreRegistryWrapper()
|
|
|
|
|
|
def _get_artifact_repo_mlflow_artifacts():
|
|
"""
|
|
Get an artifact repository specified by ``--artifacts-destination`` option for ``mlflow server``
|
|
command.
|
|
"""
|
|
from mlflow.server import ARTIFACTS_DESTINATION_ENV_VAR
|
|
|
|
global _artifact_repo
|
|
if _artifact_repo is None:
|
|
_artifact_repo = get_artifact_repository(os.environ[ARTIFACTS_DESTINATION_ENV_VAR])
|
|
return _artifact_repo
|
|
|
|
|
|
def _get_trace_artifact_repo(trace_info: TraceInfo):
|
|
"""
|
|
Resolve the artifact repository for fetching data for the given trace.
|
|
|
|
Args:
|
|
trace_info: The trace info object containing metadata about the trace.
|
|
"""
|
|
return _get_trace_repo_from_uri(get_artifact_uri_for_trace(trace_info))
|
|
|
|
|
|
def _get_trace_archive_repo(trace_info: TraceInfo):
|
|
"""
|
|
Resolve the artifact repository that stores archived trace payloads.
|
|
"""
|
|
return _get_trace_repo_from_uri(get_archive_uri_for_trace(trace_info))
|
|
|
|
|
|
def _get_trace_repo_from_uri(artifact_uri: str):
|
|
if _is_servable_proxied_run_artifact_root(artifact_uri):
|
|
# If the artifact location is a proxied run artifact root (e.g. mlflow-artifacts://...),
|
|
# we need to resolve it to the actual artifact location.
|
|
from mlflow.server import ARTIFACTS_DESTINATION_ENV_VAR
|
|
|
|
path = _get_proxied_run_artifact_destination_path(artifact_uri)
|
|
if not path:
|
|
raise MlflowException(
|
|
f"Failed to resolve the proxied run artifact URI: {artifact_uri}. ",
|
|
"Trace artifact URI must contain subpath to the trace data directory.",
|
|
error_code=BAD_REQUEST,
|
|
)
|
|
root = os.environ[ARTIFACTS_DESTINATION_ENV_VAR]
|
|
artifact_uri = posixpath.join(root, path)
|
|
|
|
# We don't set it to global var unlike run artifact, because the artifact repo has
|
|
# to be created with full trace artifact URI including request_id.
|
|
# e.g. s3://<experiment_id>/traces/<request_id>
|
|
artifact_repo = get_artifact_repository(artifact_uri)
|
|
else:
|
|
artifact_repo = get_artifact_repository(artifact_uri)
|
|
return artifact_repo
|
|
|
|
|
|
def _is_serving_proxied_artifacts():
|
|
"""
|
|
Returns:
|
|
True if the MLflow server is serving proxied artifacts (i.e. acting as a proxy for
|
|
artifact upload / download / list operations), as would be enabled by specifying the
|
|
--serve-artifacts configuration option. False otherwise.
|
|
"""
|
|
from mlflow.server import SERVE_ARTIFACTS_ENV_VAR
|
|
|
|
return os.environ.get(SERVE_ARTIFACTS_ENV_VAR, "false") == "true"
|
|
|
|
|
|
def _is_servable_proxied_run_artifact_root(run_artifact_root):
|
|
"""
|
|
Determines whether or not the following are true:
|
|
|
|
- The specified Run artifact root is a proxied artifact root (i.e. an artifact root with scheme
|
|
``http``, ``https``, or ``mlflow-artifacts``).
|
|
|
|
- The MLflow server is capable of resolving and accessing the underlying storage location
|
|
corresponding to the proxied artifact root, allowing it to fulfill artifact list and
|
|
download requests by using this storage location directly.
|
|
|
|
Args:
|
|
run_artifact_root: The Run artifact root location (URI).
|
|
|
|
Returns:
|
|
True if the specified Run artifact root refers to proxied artifacts that can be
|
|
served by this MLflow server (i.e. the server has access to the destination and
|
|
can respond to list and download requests for the artifact). False otherwise.
|
|
"""
|
|
parsed_run_artifact_root = urllib.parse.urlparse(run_artifact_root)
|
|
# NB: If the run artifact root is a proxied artifact root (has scheme `http`, `https`, or
|
|
# `mlflow-artifacts`) *and* the MLflow server is configured to serve artifacts, the MLflow
|
|
# server always assumes that it has access to the underlying storage location for the proxied
|
|
# artifacts. This may not always be accurate. For example:
|
|
#
|
|
# An organization may initially use the MLflow server to serve Tracking API requests and proxy
|
|
# access to artifacts stored in Location A (via `mlflow server --serve-artifacts`). Then, for
|
|
# scalability and / or security purposes, the organization may decide to store artifacts in a
|
|
# new location B and set up a separate server (e.g. `mlflow server --artifacts-only`) to proxy
|
|
# access to artifacts stored in Location B.
|
|
#
|
|
# In this scenario, requests for artifacts stored in Location B that are sent to the original
|
|
# MLflow server will fail if the original MLflow server does not have access to Location B
|
|
# because it will assume that it can serve all proxied artifacts regardless of the underlying
|
|
# location. Such failures can be remediated by granting the original MLflow server access to
|
|
# Location B.
|
|
return (
|
|
parsed_run_artifact_root.scheme in ["http", "https", "mlflow-artifacts"]
|
|
and _is_serving_proxied_artifacts()
|
|
)
|
|
|
|
|
|
def _get_proxied_run_artifact_destination_path(proxied_artifact_root, relative_path=None):
|
|
"""
|
|
Resolves the specified proxied artifact location within a Run to a concrete storage location.
|
|
|
|
Args:
|
|
proxied_artifact_root: The Run artifact root location (URI) with scheme ``http``,
|
|
``https``, or `mlflow-artifacts` that can be resolved by the MLflow server to a
|
|
concrete storage location.
|
|
relative_path: The relative path of the destination within the specified
|
|
``proxied_artifact_root``. If ``None``, the destination is assumed to be
|
|
the resolved ``proxied_artifact_root``.
|
|
|
|
Returns:
|
|
The storage location of the specified artifact.
|
|
"""
|
|
parsed_proxied_artifact_root = urllib.parse.urlparse(proxied_artifact_root)
|
|
assert parsed_proxied_artifact_root.scheme in ["http", "https", "mlflow-artifacts"]
|
|
|
|
if parsed_proxied_artifact_root.scheme == "mlflow-artifacts":
|
|
# If the proxied artifact root is an `mlflow-artifacts` URI, the run artifact root path is
|
|
# simply the path component of the URI, since the fully-qualified format of an
|
|
# `mlflow-artifacts` URI is `mlflow-artifacts://<netloc>/path/to/artifact`
|
|
proxied_run_artifact_root_path = parsed_proxied_artifact_root.path.lstrip("/")
|
|
else:
|
|
# In this case, the proxied artifact root is an HTTP(S) URL referring to an mlflow-artifacts
|
|
# API route that can be used to download the artifact. These routes are always anchored at
|
|
# `/api/2.0/mlflow-artifacts/artifacts`. Accordingly, we split the path on this route anchor
|
|
# and interpret the rest of the path (everything after the route anchor) as the run artifact
|
|
# root path
|
|
mlflow_artifacts_http_route_anchor = "/api/2.0/mlflow-artifacts/artifacts/"
|
|
assert mlflow_artifacts_http_route_anchor in parsed_proxied_artifact_root.path
|
|
|
|
proxied_run_artifact_root_path = parsed_proxied_artifact_root.path.split(
|
|
mlflow_artifacts_http_route_anchor
|
|
)[1].lstrip("/")
|
|
|
|
return (
|
|
posixpath.join(proxied_run_artifact_root_path, relative_path)
|
|
if relative_path is not None
|
|
else proxied_run_artifact_root_path
|
|
)
|
|
|
|
|
|
def _get_tracking_store(
|
|
backend_store_uri: str | None = None,
|
|
default_artifact_root: str | None = None,
|
|
) -> AbstractTrackingStore:
|
|
from mlflow.server import ARTIFACT_ROOT_ENV_VAR, BACKEND_STORE_URI_ENV_VAR
|
|
|
|
global _tracking_store
|
|
if _tracking_store is None:
|
|
store_uri = backend_store_uri or os.environ.get(BACKEND_STORE_URI_ENV_VAR, None)
|
|
artifact_root = default_artifact_root or os.environ.get(ARTIFACT_ROOT_ENV_VAR, None)
|
|
_tracking_store = _tracking_store_registry.get_store(store_uri, artifact_root)
|
|
utils.set_tracking_uri(store_uri)
|
|
return _tracking_store
|
|
|
|
|
|
def _get_model_registry_store(registry_store_uri: str | None = None) -> AbstractModelRegistryStore:
|
|
from mlflow.server import BACKEND_STORE_URI_ENV_VAR, REGISTRY_STORE_URI_ENV_VAR
|
|
|
|
global _model_registry_store
|
|
if _model_registry_store is None:
|
|
store_uri = (
|
|
registry_store_uri
|
|
or os.environ.get(REGISTRY_STORE_URI_ENV_VAR, None)
|
|
or os.environ.get(BACKEND_STORE_URI_ENV_VAR, None)
|
|
)
|
|
_model_registry_store = _model_registry_store_registry.get_store(store_uri)
|
|
registry_utils.set_registry_uri(store_uri)
|
|
return _model_registry_store
|
|
|
|
|
|
def _get_job_store(backend_store_uri: str | None = None) -> AbstractJobStore:
|
|
"""
|
|
Get a job store instance based on the backend store URI.
|
|
|
|
Args:
|
|
backend_store_uri: Optional backend store URI. If not provided,
|
|
uses environment variable.
|
|
|
|
Returns:
|
|
An instance of AbstractJobStore
|
|
"""
|
|
from mlflow.server import BACKEND_STORE_URI_ENV_VAR
|
|
from mlflow.store.jobs.sqlalchemy_store import SqlAlchemyJobStore
|
|
from mlflow.store.jobs.sqlalchemy_workspace_store import WorkspaceAwareSqlAlchemyJobStore
|
|
from mlflow.utils.uri import extract_db_type_from_uri
|
|
|
|
global _job_store
|
|
if _job_store is None:
|
|
store_uri = backend_store_uri or os.environ.get(BACKEND_STORE_URI_ENV_VAR, None)
|
|
if not store_uri:
|
|
raise MlflowException.invalid_parameter_value("Job store requires a backend store URI")
|
|
try:
|
|
extract_db_type_from_uri(store_uri)
|
|
except (MlflowException, ValueError):
|
|
# Require a database backend URI for the job store
|
|
# Raise MlflowException so the CLI/REST layer returns a structured 400
|
|
# instead of surfacing a generic 500 from ValueError
|
|
raise MlflowException.invalid_parameter_value("Job store requires a backend store URI")
|
|
|
|
store_cls = (
|
|
WorkspaceAwareSqlAlchemyJobStore
|
|
if MLFLOW_ENABLE_WORKSPACES.get()
|
|
else SqlAlchemyJobStore
|
|
)
|
|
_job_store = store_cls(store_uri)
|
|
|
|
if MLFLOW_ENABLE_WORKSPACES.get():
|
|
_verify_job_store_workspace_support(_job_store)
|
|
|
|
return _job_store
|
|
|
|
|
|
def initialize_backend_stores(
|
|
backend_store_uri: str | None = None,
|
|
registry_store_uri: str | None = None,
|
|
default_artifact_root: str | None = None,
|
|
workspace_store_uri: str | None = None,
|
|
read_replica_backend_store_uri: str | None = None,
|
|
) -> None:
|
|
from mlflow.server.constants import READ_REPLICA_BACKEND_STORE_URI_ENV_VAR
|
|
|
|
# Set the read backend store URI env var so _get_sqlalchemy_store can pick it up
|
|
if read_replica_backend_store_uri:
|
|
os.environ[READ_REPLICA_BACKEND_STORE_URI_ENV_VAR] = read_replica_backend_store_uri
|
|
|
|
tracking_store = _get_tracking_store(backend_store_uri, default_artifact_root)
|
|
registry_store = None
|
|
try:
|
|
registry_store = _get_model_registry_store(registry_store_uri)
|
|
except UnsupportedModelRegistryStoreURIException:
|
|
pass
|
|
|
|
if MLFLOW_ENABLE_WORKSPACES.get():
|
|
# Initialize the workspace store to verify it's correctly configured
|
|
_get_workspace_store(
|
|
workspace_uri=workspace_store_uri,
|
|
tracking_uri=backend_store_uri,
|
|
)
|
|
_verify_tracking_store_workspace_support(tracking_store)
|
|
_verify_model_registry_store_workspace_support(registry_store)
|
|
|
|
_verify_tracking_store_trace_archival_support(tracking_store)
|
|
|
|
|
|
def _store_supports_workspaces(
|
|
store: AbstractTrackingStore | AbstractModelRegistryStore | AbstractJobStore,
|
|
) -> bool:
|
|
"""Return whether the provided store reports workspace support."""
|
|
return bool(getattr(store, "supports_workspaces", False))
|
|
|
|
|
|
def _store_supports_trace_archival(store: AbstractTrackingStore) -> bool:
|
|
"""Return whether the provided tracking store reports trace archival support."""
|
|
return bool(getattr(store, "supports_trace_archival", False))
|
|
|
|
|
|
def _verify_tracking_store_workspace_support(tracking_store: AbstractTrackingStore) -> None:
|
|
if not _store_supports_workspaces(tracking_store):
|
|
raise MlflowException(
|
|
"The configured tracking store does not support workspace-aware operations. "
|
|
"Remove the --enable-workspaces flag or configure a workspace-capable backend store.",
|
|
error_code=INVALID_STATE,
|
|
)
|
|
|
|
|
|
def _verify_tracking_store_trace_archival_support(tracking_store: AbstractTrackingStore) -> None:
|
|
trace_archival_config = get_trace_archival_server_config()
|
|
if trace_archival_config is None or not trace_archival_config.enabled:
|
|
return
|
|
|
|
if not _store_supports_trace_archival(tracking_store):
|
|
raise MlflowException(
|
|
"The configured tracking store does not support server-owned trace archival. "
|
|
"Remove the trace archival config or configure a trace-archival-capable backend store.",
|
|
error_code=INVALID_STATE,
|
|
)
|
|
|
|
|
|
def _verify_model_registry_store_workspace_support(
|
|
registry_store: AbstractModelRegistryStore,
|
|
) -> None:
|
|
if registry_store is None:
|
|
return
|
|
|
|
if not _store_supports_workspaces(registry_store):
|
|
raise MlflowException(
|
|
"The configured model registry store does not support workspace-aware operations. "
|
|
"Remove the --enable-workspaces flag or configure a workspace-capable backend store.",
|
|
error_code=INVALID_STATE,
|
|
)
|
|
|
|
|
|
def _verify_job_store_workspace_support(job_store: AbstractJobStore) -> None:
|
|
if not _store_supports_workspaces(job_store):
|
|
raise MlflowException(
|
|
"The configured job store does not support workspace-aware operations. "
|
|
"Remove the --enable-workspaces flag or configure a workspace-capable backend store.",
|
|
error_code=INVALID_STATE,
|
|
)
|
|
|
|
|
|
def _assert_string(x):
|
|
assert isinstance(x, str)
|
|
|
|
|
|
def _assert_intlike(x):
|
|
try:
|
|
x = int(x)
|
|
except ValueError:
|
|
pass
|
|
|
|
assert isinstance(x, int)
|
|
|
|
|
|
def _assert_bool(x):
|
|
assert isinstance(x, bool)
|
|
|
|
|
|
def _assert_floatlike(x):
|
|
try:
|
|
x = float(x)
|
|
except ValueError:
|
|
pass
|
|
|
|
assert isinstance(x, float)
|
|
|
|
|
|
def _assert_array(x):
|
|
assert isinstance(x, list)
|
|
|
|
|
|
def _assert_dict(x):
|
|
assert isinstance(x, dict)
|
|
|
|
|
|
def _assert_map_key_present(x):
|
|
_assert_array(x)
|
|
for entry in x:
|
|
_assert_required(entry.get("key"))
|
|
|
|
|
|
def _assert_required(x, path=None):
|
|
if path is None:
|
|
assert x is not None
|
|
# When parsing JSON payloads via proto, absent string fields
|
|
# are expressed as empty strings
|
|
assert x != ""
|
|
else:
|
|
assert x is not None, missing_value(path)
|
|
assert x != "", missing_value(path)
|
|
|
|
|
|
def _assert_less_than_or_equal(x, max_value, message=None):
|
|
if x > max_value:
|
|
raise AssertionError(message) if message else AssertionError()
|
|
|
|
|
|
def _assert_intlike_within_range(x, min_value, max_value, message=None):
|
|
if not min_value <= x <= max_value:
|
|
raise AssertionError(message) if message else AssertionError()
|
|
|
|
|
|
def _assert_item_type_string(x):
|
|
assert all(isinstance(item, str) for item in x)
|
|
|
|
|
|
def _assert_secret_value(x):
|
|
"""Validate secret_value is present. Does not print values in errors."""
|
|
if x is None:
|
|
raise MlflowException(
|
|
message="Missing value for required parameter 'secret_value'.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
|
|
_TYPE_VALIDATORS = {
|
|
_assert_intlike,
|
|
_assert_string,
|
|
_assert_bool,
|
|
_assert_floatlike,
|
|
_assert_array,
|
|
_assert_item_type_string,
|
|
}
|
|
|
|
|
|
def _validate_param_against_schema(schema, param, value, proto_parsing_succeeded=False):
|
|
"""
|
|
Attempts to validate a single parameter against a specified schema. Examples of the elements of
|
|
the schema are type assertions and checks for required parameters. Returns None on validation
|
|
success. Otherwise, raises an MLFlowException if an assertion fails. This method is intended
|
|
to be called for side effects.
|
|
|
|
Args:
|
|
schema: A list of functions to validate the parameter against.
|
|
param: The string name of the parameter being validated.
|
|
value: The corresponding value of the `param` being validated.
|
|
proto_parsing_succeeded: A boolean value indicating whether proto parsing succeeded.
|
|
If the proto was successfully parsed, we assume all of the types of the parameters in
|
|
the request body were correctly specified, and thus we skip validating types. If proto
|
|
parsing failed, then we validate types in addition to the rest of the schema. For
|
|
details, see https://github.com/mlflow/mlflow/pull/5458#issuecomment-1080880870.
|
|
"""
|
|
|
|
for f in schema:
|
|
if f in _TYPE_VALIDATORS and proto_parsing_succeeded:
|
|
continue
|
|
|
|
try:
|
|
f(value)
|
|
except AssertionError as e:
|
|
if e.args:
|
|
message = e.args[0]
|
|
elif f == _assert_required:
|
|
message = f"Missing value for required parameter '{param}'."
|
|
else:
|
|
message = invalid_value(
|
|
param, value, f" Hint: Value was of type '{type(value).__name__}'."
|
|
)
|
|
raise MlflowException(
|
|
message=(
|
|
message + " See the API docs for more information about request parameters."
|
|
),
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def _get_request_json(flask_request=request):
|
|
_validate_content_type(flask_request, ["application/json"])
|
|
return flask_request.get_json(force=True, silent=True)
|
|
|
|
|
|
def _get_normalized_request_json(flask_request: Request = request) -> dict[str, Any]:
|
|
"""
|
|
Get request JSON with normalization for legacy clients.
|
|
|
|
Handles double-encoded JSON strings from older clients and empty request bodies.
|
|
|
|
Args:
|
|
flask_request: The Flask request object.
|
|
|
|
Returns:
|
|
The request data as a dictionary (empty dict if no body).
|
|
"""
|
|
request_json = _get_request_json(flask_request)
|
|
|
|
# Older clients may post their JSON double-encoded as strings, so the get_json
|
|
# above actually converts it to a string. Therefore, we check this condition
|
|
# (which we can tell for sure because any proper request should be a dictionary),
|
|
# and decode it a second time.
|
|
if is_string_type(request_json):
|
|
request_json = json.loads(request_json)
|
|
|
|
# If request doesn't have json body then assume it's empty.
|
|
if request_json is None:
|
|
request_json = {}
|
|
|
|
return request_json
|
|
|
|
|
|
def _validate_request_json_with_schema(
|
|
request_json: dict[str, Any],
|
|
schema: dict[str, list[Callable[..., Any]]] | None,
|
|
proto_parsing_succeeded: bool | None,
|
|
) -> None:
|
|
"""
|
|
Validate request JSON against a schema without requiring protobuf messages.
|
|
|
|
Args:
|
|
request_json: The request data as a dictionary.
|
|
schema: Dictionary mapping parameter names to lists of validation functions.
|
|
proto_parsing_succeeded: Whether protobuf parsing succeeded. None indicates the
|
|
request was not parsed from protobuf.
|
|
"""
|
|
schema = schema or {}
|
|
for schema_key, schema_validation_fns in schema.items():
|
|
if schema_key in request_json or _assert_required in schema_validation_fns:
|
|
value = request_json.get(schema_key)
|
|
if schema_key == "run_id" and value is None and "run_uuid" in request_json:
|
|
value = request_json.get("run_uuid")
|
|
_validate_param_against_schema(
|
|
schema=schema_validation_fns,
|
|
param=schema_key,
|
|
value=value,
|
|
proto_parsing_succeeded=proto_parsing_succeeded,
|
|
)
|
|
|
|
|
|
def _get_request_message(request_message, flask_request=request, schema=None):
|
|
if flask_request.method == "GET" and flask_request.args:
|
|
# Convert atomic values of repeated fields to lists before calling protobuf deserialization.
|
|
# Context: We parse the parameter string into a dictionary outside of protobuf since
|
|
# protobuf does not know how to read the query parameters directly. The query parser above
|
|
# has no type information and hence any parameter that occurs exactly once is parsed as an
|
|
# atomic value. Since protobuf requires that the values of repeated fields are lists,
|
|
# deserialization will fail unless we do the fix below.
|
|
request_json = {}
|
|
for field in request_message.DESCRIPTOR.fields:
|
|
if field.name not in flask_request.args:
|
|
continue
|
|
|
|
# Use is_repeated property (preferred) with fallback to deprecated label
|
|
try:
|
|
is_repeated = field.is_repeated
|
|
except AttributeError:
|
|
is_repeated = field.label == descriptor.FieldDescriptor.LABEL_REPEATED
|
|
|
|
if is_repeated:
|
|
request_json[field.name] = flask_request.args.getlist(field.name)
|
|
else:
|
|
value = flask_request.args.get(field.name)
|
|
if field.type == descriptor.FieldDescriptor.TYPE_BOOL and isinstance(value, str):
|
|
if value.lower() not in ["true", "false"]:
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Invalid boolean value: {value}, must be 'true' or 'false'.",
|
|
)
|
|
value = value.lower() == "true"
|
|
request_json[field.name] = value
|
|
else:
|
|
request_json = _get_normalized_request_json(flask_request)
|
|
|
|
proto_parsing_succeeded = True
|
|
try:
|
|
parse_dict(request_json, request_message)
|
|
except ParseError:
|
|
proto_parsing_succeeded = False
|
|
|
|
_validate_request_json_with_schema(request_json, schema, proto_parsing_succeeded)
|
|
|
|
return request_message
|
|
|
|
|
|
def _get_validated_flask_request_json(
|
|
flask_request: Request = request,
|
|
schema: dict[str, list[Callable[..., Any]]] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Get and validate request data without protobuf parsing.
|
|
|
|
This is an alternative to _get_request_message for endpoints that don't
|
|
use protobuf message definitions. Supports both GET and POST/PUT requests.
|
|
|
|
Args:
|
|
flask_request: The Flask request object.
|
|
schema: Dictionary mapping parameter names to lists of validation functions.
|
|
|
|
Returns:
|
|
The validated request data as a dictionary.
|
|
|
|
Raises:
|
|
MlflowException: If validation fails.
|
|
"""
|
|
if flask_request.method == "GET" and flask_request.args:
|
|
# Extract query parameters for GET requests
|
|
request_json = {}
|
|
schema = schema or {}
|
|
for key in flask_request.args:
|
|
# Get all values for this key (supports repeated parameters)
|
|
values = flask_request.args.getlist(key)
|
|
# Check if this field is a list type by looking for _assert_array validator
|
|
is_list_type = _assert_array in schema.get(key, [])
|
|
# If list type, always keep as list; otherwise use scalar if only one value
|
|
request_json[key] = (
|
|
values if is_list_type else (values[0] if len(values) == 1 else values)
|
|
)
|
|
else:
|
|
request_json = _get_normalized_request_json(flask_request)
|
|
|
|
_validate_request_json_with_schema(request_json, schema, proto_parsing_succeeded=None)
|
|
|
|
return request_json
|
|
|
|
|
|
def _content_disposition_attachment(filename: str) -> str:
|
|
"""
|
|
Build an RFC 6266 / RFC 5987 ``Content-Disposition`` value for an attachment.
|
|
|
|
HTTP headers must be ASCII-encodable; ASGI adapters such as starlette's
|
|
``WSGIMiddleware`` raise ``UnicodeEncodeError`` on raw non-ASCII bytes. For
|
|
filenames containing non-ASCII characters, emit an ASCII ``filename=``
|
|
fallback alongside a percent-encoded ``filename*=UTF-8''…`` parameter.
|
|
"""
|
|
try:
|
|
filename.encode("ascii")
|
|
except UnicodeEncodeError:
|
|
# ``or "download"`` ensures a well-formed ``filename=<value>`` parameter
|
|
# even when normalization strips every character (e.g. ``日本語`` with no
|
|
# extension). Clients that ignore ``filename*`` still get a usable name.
|
|
ascii_fallback = (
|
|
unicodedata.normalize("NFKD", filename).encode("ascii", "ignore").decode("ascii")
|
|
or "download"
|
|
)
|
|
# safe = RFC 5987 attr-char
|
|
quoted = urllib.parse.quote(filename, safe="!#$&+-.^_`|~")
|
|
quoted_ascii_fallback = quote_header_value(ascii_fallback, allow_token=True)
|
|
return f"attachment; filename={quoted_ascii_fallback}; filename*=UTF-8''{quoted}"
|
|
quoted_filename = quote_header_value(filename, allow_token=True)
|
|
return f"attachment; filename={quoted_filename}"
|
|
|
|
|
|
def _response_with_file_attachment_headers(file_path, response):
|
|
mime_type = _guess_mime_type(file_path)
|
|
filename = pathlib.Path(file_path).name
|
|
response.mimetype = mime_type
|
|
content_disposition_header_name = "Content-Disposition"
|
|
if content_disposition_header_name not in response.headers:
|
|
response.headers[content_disposition_header_name] = _content_disposition_attachment(
|
|
filename
|
|
)
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
response.headers["Content-Type"] = mime_type
|
|
return response
|
|
|
|
|
|
def _create_artifact_file_response(file_path: str, artifact_name: str) -> Response:
|
|
"""Serve a local file while preserving the logical artifact name for downloads."""
|
|
if os.path.isdir(file_path):
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Artifact path refers to a directory, not a file: '{artifact_name}'"
|
|
)
|
|
file_sender_response = send_file(file_path, mimetype=_guess_mime_type(file_path))
|
|
file_sender_response.headers["Content-Disposition"] = _content_disposition_attachment(
|
|
pathlib.Path(artifact_name).name
|
|
)
|
|
return _response_with_file_attachment_headers(file_path, file_sender_response)
|
|
|
|
|
|
def _send_artifact(artifact_repository, path):
|
|
# Always send artifacts as attachments to prevent the browser from displaying them on our web
|
|
# server's domain, which might enable XSS.
|
|
if (local_path := artifact_repository.get_local_path(path)) is not None:
|
|
return _create_artifact_file_response(os.path.abspath(local_path), path)
|
|
|
|
tmp_dir = tempfile.TemporaryDirectory()
|
|
try:
|
|
file_path = os.path.abspath(
|
|
artifact_repository.download_artifacts(path, dst_path=tmp_dir.name)
|
|
)
|
|
response = _create_artifact_file_response(file_path, path)
|
|
response.call_on_close(tmp_dir.cleanup)
|
|
return response
|
|
except Exception:
|
|
tmp_dir.cleanup()
|
|
raise
|
|
|
|
|
|
def catch_mlflow_exception(func):
|
|
@wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
try:
|
|
return func(*args, **kwargs)
|
|
except MlflowException as e:
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(e.serialize_as_json())
|
|
response.status_code = e.get_http_status_code()
|
|
if response.status_code >= 500:
|
|
is_debug = _logger.isEnabledFor(logging.DEBUG)
|
|
msg = f"Error in {func.__name__}: {e}"
|
|
if not is_debug:
|
|
msg += ". Set MLFLOW_LOGGING_LEVEL=DEBUG for traceback."
|
|
_logger.error(msg, exc_info=is_debug)
|
|
return response
|
|
|
|
return wrapper
|
|
|
|
|
|
def _disable_unless_serve_artifacts(func):
|
|
@wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
if not _is_serving_proxied_artifacts():
|
|
return Response(
|
|
(
|
|
f"Endpoint: {request.url_rule} disabled due to the mlflow server running "
|
|
"with `--no-serve-artifacts`. To enable artifacts server functionality, "
|
|
"run `mlflow server` with `--serve-artifacts`"
|
|
),
|
|
503,
|
|
)
|
|
return func(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
|
|
def _disable_if_artifacts_only(func):
|
|
@wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
from mlflow.server import ARTIFACTS_ONLY_ENV_VAR
|
|
|
|
if os.environ.get(ARTIFACTS_ONLY_ENV_VAR):
|
|
return Response(
|
|
(
|
|
f"Endpoint: {request.url_rule} disabled due to the mlflow server running "
|
|
"in `--artifacts-only` mode. To enable tracking server functionality, run "
|
|
"`mlflow server` without `--artifacts-only`"
|
|
),
|
|
503,
|
|
)
|
|
return func(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
|
|
def _disable_if_workspaces_disabled(func):
|
|
@wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
if not MLFLOW_ENABLE_WORKSPACES.get():
|
|
return Response(
|
|
(
|
|
f"Endpoint: {request.url_rule} disabled because the server is running "
|
|
"without workspaces support. To enable workspace, run "
|
|
"`mlflow server` with `--enable-workspaces`"
|
|
),
|
|
503,
|
|
)
|
|
return func(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
|
|
def _workspace_not_supported(message: str) -> MlflowException:
|
|
return MlflowException(message, FEATURE_DISABLED)
|
|
|
|
|
|
def _validate_storage_location_uri(value: str, field_name: str) -> str:
|
|
"""Validate a storage URI shared by experiment and workspace settings."""
|
|
parsed = urllib.parse.urlparse(value)
|
|
if parsed.fragment or parsed.params:
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"'{field_name}' URL can't include fragments or params."
|
|
)
|
|
|
|
validate_query_string(parsed.query)
|
|
_validate_experiment_artifact_location(value)
|
|
_validate_experiment_artifact_location_length(value)
|
|
return value
|
|
|
|
|
|
def _validate_optional_workspace_storage_location(value: str | None, field_name: str) -> str | None:
|
|
if value is None:
|
|
return None
|
|
|
|
trimmed = value.strip()
|
|
if not trimmed:
|
|
return ""
|
|
|
|
return _validate_storage_location_uri(trimmed, field_name)
|
|
|
|
|
|
def _validate_workspace_default_artifact_root(value: str | None) -> str | None:
|
|
return _validate_optional_workspace_storage_location(value, "default_artifact_root")
|
|
|
|
|
|
def _validate_workspace_trace_archival_location(value: str | None) -> str | None:
|
|
validated = _validate_optional_workspace_storage_location(
|
|
value, "trace_archival_config.location"
|
|
)
|
|
if validated in (None, ""):
|
|
return validated
|
|
return _validate_trace_archival_location(
|
|
validated, parameter_name="trace_archival_config.location"
|
|
)
|
|
|
|
|
|
def _validate_workspace_trace_archival_retention(value: str | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
|
|
trimmed = value.strip()
|
|
if not trimmed:
|
|
return ""
|
|
|
|
return _validate_trace_archival_retention_string(
|
|
trimmed, parameter_name="trace_archival_config.retention"
|
|
)
|
|
|
|
|
|
def _get_workspace_request_message(
|
|
request_message, schema: dict[str, list[Callable[..., Any]]] | None = None
|
|
) -> tuple[Any, dict[str, Any]]:
|
|
request_json = _get_normalized_request_json()
|
|
_validate_request_json_with_schema(request_json, schema, proto_parsing_succeeded=None)
|
|
|
|
trace_archival_config_json = request_json.get("trace_archival_config")
|
|
if trace_archival_config_json is not None:
|
|
_validate_param_against_schema(
|
|
schema=[_assert_dict],
|
|
param="trace_archival_config",
|
|
value=trace_archival_config_json,
|
|
proto_parsing_succeeded=False,
|
|
)
|
|
_validate_request_json_with_schema(
|
|
trace_archival_config_json,
|
|
{
|
|
"location": [_assert_string],
|
|
"retention": [_assert_string],
|
|
},
|
|
proto_parsing_succeeded=None,
|
|
)
|
|
|
|
parse_dict(request_json, request_message)
|
|
return request_message, request_json
|
|
|
|
|
|
def _ensure_artifact_root_available(workspace_artifact_root: str | None) -> None:
|
|
"""Ensure an artifact root is available either at workspace or server level.
|
|
|
|
Args:
|
|
workspace_artifact_root: The workspace's default_artifact_root value.
|
|
- None means "not specified" (fallback to server default)
|
|
- "" means "clear/unset" (fallback to server default)
|
|
- non-empty string means "use this workspace-specific root"
|
|
|
|
Raises:
|
|
MlflowException: If neither workspace nor server has an artifact root configured.
|
|
"""
|
|
# If workspace has a non-empty artifact root, it's valid
|
|
if workspace_artifact_root:
|
|
return
|
|
|
|
# Otherwise, check if server has a default artifact root
|
|
server_artifact_root = _get_tracking_store().artifact_root_uri
|
|
if not server_artifact_root:
|
|
raise MlflowException.invalid_parameter_value(
|
|
"Cannot create or update workspace without an artifact root. Either specify "
|
|
"'default_artifact_root' for this workspace or start the server with "
|
|
"'--default-artifact-root'."
|
|
)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_workspaces_disabled
|
|
def _list_workspaces_handler():
|
|
_get_request_message(ListWorkspaces())
|
|
workspaces = _get_workspace_store().list_workspaces()
|
|
response_message = ListWorkspaces.Response()
|
|
response_message.workspaces.extend([ws.to_proto() for ws in workspaces])
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_workspaces_disabled
|
|
def _create_workspace_handler():
|
|
request_message, request_json = _get_workspace_request_message(
|
|
CreateWorkspace(),
|
|
schema={
|
|
"name": [_assert_required, _assert_string],
|
|
"description": [_assert_string],
|
|
"default_artifact_root": [_assert_string],
|
|
},
|
|
)
|
|
|
|
if request_message.name == DEFAULT_WORKSPACE_NAME:
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"The '{DEFAULT_WORKSPACE_NAME}' workspace is reserved and cannot be created"
|
|
)
|
|
WorkspaceNameValidator.validate(request_message.name)
|
|
description = request_message.description if request_message.HasField("description") else None
|
|
default_artifact_root = (
|
|
request_message.default_artifact_root
|
|
if request_message.HasField("default_artifact_root")
|
|
else None
|
|
)
|
|
trace_archival_config_json = request_json.get("trace_archival_config") or {}
|
|
has_trace_archival_location = "location" in trace_archival_config_json
|
|
has_trace_archival_retention = "retention" in trace_archival_config_json
|
|
trace_archival_location = (
|
|
request_message.trace_archival_config.location if has_trace_archival_location else None
|
|
)
|
|
trace_archival_retention = (
|
|
request_message.trace_archival_config.retention if has_trace_archival_retention else None
|
|
)
|
|
default_artifact_root = _validate_workspace_default_artifact_root(default_artifact_root)
|
|
trace_archival_location = _validate_workspace_trace_archival_location(trace_archival_location)
|
|
trace_archival_retention = _validate_workspace_trace_archival_retention(
|
|
trace_archival_retention
|
|
)
|
|
_ensure_artifact_root_available(default_artifact_root)
|
|
store = _get_workspace_store()
|
|
try:
|
|
workspace = store.create_workspace(
|
|
Workspace(
|
|
name=request_message.name,
|
|
description=description,
|
|
default_artifact_root=default_artifact_root,
|
|
trace_archival_location=trace_archival_location,
|
|
trace_archival_retention=trace_archival_retention,
|
|
)
|
|
)
|
|
except NotImplementedError:
|
|
raise _workspace_not_supported("Workspace creation is not supported by this provider")
|
|
|
|
response_message = CreateWorkspace.Response()
|
|
response_message.workspace.MergeFrom(workspace.to_proto())
|
|
response = _wrap_response(response_message)
|
|
response.status_code = 201
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_workspaces_disabled
|
|
def _get_workspace_handler(workspace_name: str):
|
|
if workspace_name != DEFAULT_WORKSPACE_NAME:
|
|
WorkspaceNameValidator.validate(workspace_name)
|
|
workspace = _get_workspace_store().get_workspace(workspace_name)
|
|
response_message = GetWorkspace.Response()
|
|
response_message.workspace.MergeFrom(workspace.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_workspaces_disabled
|
|
def _update_workspace_handler(workspace_name: str):
|
|
if workspace_name != DEFAULT_WORKSPACE_NAME:
|
|
WorkspaceNameValidator.validate(workspace_name)
|
|
request_message, request_json = _get_workspace_request_message(
|
|
UpdateWorkspace(),
|
|
schema={
|
|
"description": [_assert_string],
|
|
"default_artifact_root": [_assert_string],
|
|
},
|
|
)
|
|
|
|
has_description = "description" in request_json
|
|
has_artifact_root = "default_artifact_root" in request_json
|
|
trace_archival_config_json = request_json.get("trace_archival_config") or {}
|
|
has_trace_archival_location = "location" in trace_archival_config_json
|
|
has_trace_archival_retention = "retention" in trace_archival_config_json
|
|
|
|
if (
|
|
not has_description
|
|
and not has_artifact_root
|
|
and not has_trace_archival_location
|
|
and not has_trace_archival_retention
|
|
):
|
|
raise MlflowException.invalid_parameter_value("Workspace update must have at least one key")
|
|
|
|
description = request_message.description if has_description else None
|
|
default_artifact_root = request_message.default_artifact_root if has_artifact_root else None
|
|
trace_archival_location = (
|
|
request_message.trace_archival_config.location if has_trace_archival_location else None
|
|
)
|
|
trace_archival_retention = (
|
|
request_message.trace_archival_config.retention if has_trace_archival_retention else None
|
|
)
|
|
default_artifact_root = _validate_workspace_default_artifact_root(default_artifact_root)
|
|
trace_archival_location = _validate_workspace_trace_archival_location(trace_archival_location)
|
|
trace_archival_retention = _validate_workspace_trace_archival_retention(
|
|
trace_archival_retention
|
|
)
|
|
|
|
# If the user is clearing the workspace artifact root (empty string), ensure the server
|
|
# has a default artifact root configured
|
|
if default_artifact_root == "":
|
|
_ensure_artifact_root_available(default_artifact_root)
|
|
|
|
store = _get_workspace_store()
|
|
try:
|
|
workspace = store.update_workspace(
|
|
Workspace(
|
|
name=workspace_name,
|
|
description=description,
|
|
default_artifact_root=default_artifact_root,
|
|
trace_archival_location=trace_archival_location,
|
|
trace_archival_retention=trace_archival_retention,
|
|
)
|
|
)
|
|
except NotImplementedError:
|
|
raise _workspace_not_supported("Workspace updates are not supported by this provider")
|
|
|
|
response_message = UpdateWorkspace.Response()
|
|
response_message.workspace.MergeFrom(workspace.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_workspaces_disabled
|
|
def _delete_workspace_handler(workspace_name: str):
|
|
if workspace_name == DEFAULT_WORKSPACE_NAME:
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"The '{DEFAULT_WORKSPACE_NAME}' workspace is reserved and cannot be deleted"
|
|
)
|
|
WorkspaceNameValidator.validate(workspace_name)
|
|
mode_str = request.args.get("mode", WorkspaceDeletionMode.RESTRICT.value)
|
|
try:
|
|
mode = WorkspaceDeletionMode(mode_str)
|
|
except ValueError:
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Invalid deletion mode '{mode_str}'. "
|
|
f"Must be one of: {', '.join(m.value for m in WorkspaceDeletionMode)}"
|
|
)
|
|
store = _get_workspace_store()
|
|
try:
|
|
store.delete_workspace(workspace_name, mode=mode)
|
|
except NotImplementedError:
|
|
raise _workspace_not_supported("Workspace deletion is not supported by this provider")
|
|
return Response(status=204)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def get_artifact_handler():
|
|
run_id = request.args.get("run_id") or request.args.get("run_uuid")
|
|
path = request.args["path"]
|
|
path = validate_path_is_safe(path)
|
|
run = _get_tracking_store().get_run(run_id)
|
|
|
|
if _is_servable_proxied_run_artifact_root(run.info.artifact_uri):
|
|
artifact_repo = _get_artifact_repo_mlflow_artifacts()
|
|
artifact_path = _get_proxied_run_artifact_destination_path(
|
|
proxied_artifact_root=run.info.artifact_uri,
|
|
relative_path=path,
|
|
)
|
|
artifact_path = _get_workspace_scoped_repo_path_if_enabled(artifact_path)
|
|
else:
|
|
artifact_repo = _get_artifact_repo(run)
|
|
artifact_path = path
|
|
|
|
return _send_artifact(artifact_repo, artifact_path)
|
|
|
|
|
|
def _not_implemented():
|
|
response = Response()
|
|
response.status_code = 404
|
|
return response
|
|
|
|
|
|
# Tracking Server APIs
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _create_experiment():
|
|
request_message = _get_request_message(
|
|
CreateExperiment(),
|
|
schema={
|
|
"name": [_assert_required, _assert_string],
|
|
"artifact_location": [_assert_string],
|
|
"tags": [_assert_array],
|
|
},
|
|
)
|
|
|
|
tags = [ExperimentTag(tag.key, tag.value) for tag in request_message.tags]
|
|
|
|
if request_message.artifact_location:
|
|
_validate_storage_location_uri(request_message.artifact_location, "artifact_location")
|
|
experiment_id = _get_tracking_store().create_experiment(
|
|
request_message.name, request_message.artifact_location, tags
|
|
)
|
|
response_message = CreateExperiment.Response()
|
|
response_message.experiment_id = experiment_id
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_experiment():
|
|
request_message = _get_request_message(
|
|
GetExperiment(), schema={"experiment_id": [_assert_required, _assert_string]}
|
|
)
|
|
response_message = get_experiment_impl(request_message)
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
def get_experiment_impl(request_message):
|
|
response_message = GetExperiment.Response()
|
|
experiment = _get_tracking_store().get_experiment(request_message.experiment_id).to_proto()
|
|
response_message.experiment.MergeFrom(experiment)
|
|
return response_message
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_experiment_by_name():
|
|
request_message = _get_request_message(
|
|
GetExperimentByName(),
|
|
schema={"experiment_name": [_assert_required, _assert_string]},
|
|
)
|
|
response_message = GetExperimentByName.Response()
|
|
store_exp = _get_tracking_store().get_experiment_by_name(request_message.experiment_name)
|
|
if store_exp is None:
|
|
raise MlflowException(
|
|
f"Could not find experiment with name '{request_message.experiment_name}'",
|
|
error_code=RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
experiment = store_exp.to_proto()
|
|
response_message.experiment.MergeFrom(experiment)
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_experiment():
|
|
request_message = _get_request_message(
|
|
DeleteExperiment(), schema={"experiment_id": [_assert_required, _assert_string]}
|
|
)
|
|
_get_tracking_store().delete_experiment(request_message.experiment_id)
|
|
response_message = DeleteExperiment.Response()
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _restore_experiment():
|
|
request_message = _get_request_message(
|
|
RestoreExperiment(),
|
|
schema={"experiment_id": [_assert_required, _assert_string]},
|
|
)
|
|
_get_tracking_store().restore_experiment(request_message.experiment_id)
|
|
response_message = RestoreExperiment.Response()
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _update_experiment():
|
|
request_message = _get_request_message(
|
|
UpdateExperiment(),
|
|
schema={
|
|
"experiment_id": [_assert_required, _assert_string],
|
|
"new_name": [_assert_string, _assert_required],
|
|
},
|
|
)
|
|
if request_message.new_name:
|
|
_get_tracking_store().rename_experiment(
|
|
request_message.experiment_id, request_message.new_name
|
|
)
|
|
response_message = UpdateExperiment.Response()
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _create_run():
|
|
request_message = _get_request_message(
|
|
CreateRun(),
|
|
schema={
|
|
"experiment_id": [_assert_string],
|
|
"start_time": [_assert_intlike],
|
|
"run_name": [_assert_string],
|
|
},
|
|
)
|
|
|
|
tags = [RunTag(tag.key, tag.value) for tag in request_message.tags]
|
|
run = _get_tracking_store().create_run(
|
|
experiment_id=request_message.experiment_id,
|
|
user_id=request_message.user_id,
|
|
start_time=request_message.start_time,
|
|
tags=tags,
|
|
run_name=request_message.run_name,
|
|
)
|
|
|
|
response_message = CreateRun.Response()
|
|
response_message.run.MergeFrom(run.to_proto())
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _update_run():
|
|
request_message = _get_request_message(
|
|
UpdateRun(),
|
|
schema={
|
|
"run_id": [_assert_required, _assert_string],
|
|
"end_time": [_assert_intlike],
|
|
"status": [_assert_string],
|
|
"run_name": [_assert_string],
|
|
},
|
|
)
|
|
run_id = request_message.run_id or request_message.run_uuid
|
|
run_name = request_message.run_name if request_message.HasField("run_name") else None
|
|
end_time = request_message.end_time if request_message.HasField("end_time") else None
|
|
status = request_message.status if request_message.HasField("status") else None
|
|
updated_info = _get_tracking_store().update_run_info(run_id, status, end_time, run_name)
|
|
response_message = UpdateRun.Response(run_info=updated_info.to_proto())
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_run():
|
|
request_message = _get_request_message(
|
|
DeleteRun(), schema={"run_id": [_assert_required, _assert_string]}
|
|
)
|
|
_get_tracking_store().delete_run(request_message.run_id)
|
|
response_message = DeleteRun.Response()
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _restore_run():
|
|
request_message = _get_request_message(
|
|
RestoreRun(), schema={"run_id": [_assert_required, _assert_string]}
|
|
)
|
|
_get_tracking_store().restore_run(request_message.run_id)
|
|
response_message = RestoreRun.Response()
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _log_metric():
|
|
request_message = _get_request_message(
|
|
LogMetric(),
|
|
schema={
|
|
"run_id": [_assert_required, _assert_string],
|
|
"key": [_assert_required, _assert_string],
|
|
"value": [_assert_required, _assert_floatlike],
|
|
"timestamp": [_assert_intlike, _assert_required],
|
|
"step": [_assert_intlike],
|
|
"model_id": [_assert_string],
|
|
"dataset_name": [_assert_string],
|
|
"dataset_digest": [_assert_string],
|
|
},
|
|
)
|
|
metric = Metric(
|
|
request_message.key,
|
|
request_message.value,
|
|
request_message.timestamp,
|
|
request_message.step,
|
|
request_message.model_id or None,
|
|
request_message.dataset_name or None,
|
|
request_message.dataset_digest or None,
|
|
request_message.run_id or None,
|
|
)
|
|
run_id = request_message.run_id or request_message.run_uuid
|
|
_get_tracking_store().log_metric(run_id, metric)
|
|
response_message = LogMetric.Response()
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _log_param():
|
|
request_message = _get_request_message(
|
|
LogParam(),
|
|
schema={
|
|
"run_id": [_assert_required, _assert_string],
|
|
"key": [_assert_required, _assert_string],
|
|
"value": [_assert_string],
|
|
},
|
|
)
|
|
param = Param(request_message.key, request_message.value)
|
|
run_id = request_message.run_id or request_message.run_uuid
|
|
_get_tracking_store().log_param(run_id, param)
|
|
response_message = LogParam.Response()
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _log_inputs():
|
|
request_message = _get_request_message(
|
|
LogInputs(),
|
|
schema={
|
|
"run_id": [_assert_required, _assert_string],
|
|
"datasets": [_assert_array],
|
|
"models": [_assert_array],
|
|
},
|
|
)
|
|
run_id = request_message.run_id
|
|
datasets = [
|
|
DatasetInput.from_proto(proto_dataset_input)
|
|
for proto_dataset_input in request_message.datasets
|
|
]
|
|
models = (
|
|
[
|
|
LoggedModelInput.from_proto(proto_logged_model_input)
|
|
for proto_logged_model_input in request_message.models
|
|
]
|
|
if request_message.models
|
|
else None
|
|
)
|
|
|
|
_get_tracking_store().log_inputs(run_id, datasets=datasets, models=models)
|
|
response_message = LogInputs.Response()
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _log_outputs():
|
|
request_message = _get_request_message(
|
|
LogOutputs(),
|
|
schema={
|
|
"run_id": [_assert_required, _assert_string],
|
|
"models": [_assert_required, _assert_array],
|
|
},
|
|
)
|
|
models = [LoggedModelOutput.from_proto(p) for p in request_message.models]
|
|
_get_tracking_store().log_outputs(run_id=request_message.run_id, models=models)
|
|
response_message = LogOutputs.Response()
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _set_experiment_tag():
|
|
request_message = _get_request_message(
|
|
SetExperimentTag(),
|
|
schema={
|
|
"experiment_id": [_assert_required, _assert_string],
|
|
"key": [_assert_required, _assert_string],
|
|
"value": [_assert_string],
|
|
},
|
|
)
|
|
tag = ExperimentTag(request_message.key, request_message.value)
|
|
_get_tracking_store().set_experiment_tag(request_message.experiment_id, tag)
|
|
response_message = SetExperimentTag.Response()
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_experiment_tag():
|
|
request_message = _get_request_message(
|
|
DeleteExperimentTag(),
|
|
schema={
|
|
"experiment_id": [_assert_required, _assert_string],
|
|
"key": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
_get_tracking_store().delete_experiment_tag(request_message.experiment_id, request_message.key)
|
|
response_message = DeleteExperimentTag.Response()
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _set_tag():
|
|
request_message = _get_request_message(
|
|
SetTag(),
|
|
schema={
|
|
"run_id": [_assert_required, _assert_string],
|
|
"key": [_assert_required, _assert_string],
|
|
"value": [_assert_string],
|
|
},
|
|
)
|
|
tag = RunTag(request_message.key, request_message.value)
|
|
run_id = request_message.run_id or request_message.run_uuid
|
|
_get_tracking_store().set_tag(run_id, tag)
|
|
response_message = SetTag.Response()
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_tag():
|
|
request_message = _get_request_message(
|
|
DeleteTag(),
|
|
schema={
|
|
"run_id": [_assert_required, _assert_string],
|
|
"key": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
_get_tracking_store().delete_tag(request_message.run_id, request_message.key)
|
|
response_message = DeleteTag.Response()
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_run():
|
|
request_message = _get_request_message(
|
|
GetRun(), schema={"run_id": [_assert_required, _assert_string]}
|
|
)
|
|
response_message = get_run_impl(request_message)
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
def get_run_impl(request_message):
|
|
response_message = GetRun.Response()
|
|
run_id = request_message.run_id or request_message.run_uuid
|
|
response_message.run.MergeFrom(_get_tracking_store().get_run(run_id).to_proto())
|
|
return response_message
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _search_runs():
|
|
request_message = _get_request_message(
|
|
SearchRuns(),
|
|
schema={
|
|
"experiment_ids": [_assert_array],
|
|
"filter": [_assert_string],
|
|
"max_results": [
|
|
_assert_intlike,
|
|
lambda x: _assert_less_than_or_equal(int(x), 50000),
|
|
],
|
|
"order_by": [_assert_array, _assert_item_type_string],
|
|
},
|
|
)
|
|
response_message = search_runs_impl(request_message)
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
def search_runs_impl(request_message):
|
|
response_message = SearchRuns.Response()
|
|
run_view_type = ViewType.ACTIVE_ONLY
|
|
if request_message.HasField("run_view_type"):
|
|
run_view_type = ViewType.from_proto(request_message.run_view_type)
|
|
filter_string = request_message.filter
|
|
max_results = request_message.max_results
|
|
experiment_ids = list(request_message.experiment_ids)
|
|
|
|
# NB: Local import to avoid circular dependency (auth imports from handlers)
|
|
try:
|
|
from mlflow.server import auth
|
|
|
|
if auth.auth_config:
|
|
experiment_ids = auth.filter_experiment_ids(experiment_ids)
|
|
except ImportError:
|
|
# Auth module not available (Flask-WTF not installed), skip filtering
|
|
pass
|
|
|
|
order_by = request_message.order_by
|
|
run_entities = _get_tracking_store().search_runs(
|
|
experiment_ids=experiment_ids,
|
|
filter_string=filter_string,
|
|
run_view_type=run_view_type,
|
|
max_results=max_results,
|
|
order_by=order_by,
|
|
page_token=request_message.page_token or None,
|
|
)
|
|
response_message.runs.extend([r.to_proto() for r in run_entities])
|
|
if run_entities.token:
|
|
response_message.next_page_token = run_entities.token
|
|
return response_message
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _list_artifacts():
|
|
request_message = _get_request_message(
|
|
ListArtifacts(),
|
|
schema={
|
|
"run_id": [_assert_string, _assert_required],
|
|
"path": [_assert_string],
|
|
"page_token": [_assert_string],
|
|
},
|
|
)
|
|
response_message = list_artifacts_impl(request_message)
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
def list_artifacts_impl(request_message):
|
|
response_message = ListArtifacts.Response()
|
|
if request_message.HasField("path"):
|
|
path = request_message.path
|
|
path = validate_path_is_safe(path)
|
|
else:
|
|
path = None
|
|
run_id = request_message.run_id or request_message.run_uuid
|
|
run = _get_tracking_store().get_run(run_id)
|
|
|
|
if _is_servable_proxied_run_artifact_root(run.info.artifact_uri):
|
|
artifact_entities = _list_artifacts_for_proxied_run_artifact_root(
|
|
proxied_artifact_root=run.info.artifact_uri,
|
|
relative_path=path,
|
|
)
|
|
else:
|
|
artifact_entities = _get_artifact_repo(run).list_artifacts(path)
|
|
|
|
response_message.files.extend([a.to_proto() for a in artifact_entities])
|
|
response_message.root_uri = run.info.artifact_uri
|
|
return response_message
|
|
|
|
|
|
def _list_artifacts_for_proxied_run_artifact_root(proxied_artifact_root, relative_path=None):
|
|
"""
|
|
Lists artifacts from the specified ``relative_path`` within the specified proxied Run artifact
|
|
root (i.e. a Run artifact root with scheme ``http``, ``https``, or ``mlflow-artifacts``).
|
|
|
|
Args:
|
|
proxied_artifact_root: The Run artifact root location (URI) with scheme ``http``,
|
|
``https``, or ``mlflow-artifacts`` that can be resolved by the
|
|
MLflow server to a concrete storage location.
|
|
relative_path: The relative path within the specified ``proxied_artifact_root`` under
|
|
which to list artifact contents. If ``None``, artifacts are listed from
|
|
the ``proxied_artifact_root`` directory.
|
|
"""
|
|
parsed_proxied_artifact_root = urllib.parse.urlparse(proxied_artifact_root)
|
|
assert parsed_proxied_artifact_root.scheme in ["http", "https", "mlflow-artifacts"]
|
|
|
|
artifact_destination_repo = _get_artifact_repo_mlflow_artifacts()
|
|
artifact_destination_path = _get_proxied_run_artifact_destination_path(
|
|
proxied_artifact_root=proxied_artifact_root,
|
|
relative_path=relative_path,
|
|
)
|
|
artifact_destination_path = _get_workspace_scoped_repo_path_if_enabled(
|
|
artifact_destination_path
|
|
)
|
|
|
|
artifact_entities = []
|
|
for file_info in artifact_destination_repo.list_artifacts(artifact_destination_path):
|
|
basename = posixpath.basename(file_info.path)
|
|
run_relative_artifact_path = (
|
|
posixpath.join(relative_path, basename) if relative_path else basename
|
|
)
|
|
artifact_entities.append(
|
|
FileInfo(run_relative_artifact_path, file_info.is_dir, file_info.file_size)
|
|
)
|
|
|
|
return artifact_entities
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_metric_history():
|
|
request_message = _get_request_message(
|
|
GetMetricHistory(),
|
|
schema={
|
|
"run_id": [_assert_string, _assert_required],
|
|
"metric_key": [_assert_string, _assert_required],
|
|
"page_token": [_assert_string],
|
|
},
|
|
)
|
|
response_message = GetMetricHistory.Response()
|
|
run_id = request_message.run_id or request_message.run_uuid
|
|
|
|
# NB: An unset proto2 int field reads as 0 (never None), so a `max_results is not None`
|
|
# check would treat requests without `max_results` as `max_results=0`: the store queries
|
|
# one row beyond the requested page size (LIMIT 1), concludes more results exist,
|
|
# truncates the page to zero metrics, and emits a token for `offset + 0` that points back
|
|
# at the same position forever. Use HasField to keep requests without `max_results` on the
|
|
# documented non-paginated path, and reject explicit non-positive page sizes.
|
|
max_results = request_message.max_results if request_message.HasField("max_results") else None
|
|
if max_results is not None and max_results <= 0:
|
|
raise MlflowException(
|
|
f"Invalid value {max_results} for parameter 'max_results' supplied. "
|
|
"It must be a positive integer.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
metric_entities = _get_tracking_store().get_metric_history(
|
|
run_id,
|
|
request_message.metric_key,
|
|
max_results=max_results,
|
|
page_token=request_message.page_token or None,
|
|
)
|
|
response_message.metrics.extend([m.to_proto() for m in metric_entities])
|
|
|
|
# Set next_page_token if available
|
|
if next_page_token := metric_entities.token:
|
|
response_message.next_page_token = next_page_token
|
|
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def get_metric_history_bulk_handler():
|
|
MAX_HISTORY_RESULTS = 25000
|
|
MAX_RUN_IDS_PER_REQUEST = 100
|
|
run_ids = request.args.to_dict(flat=False).get("run_id", [])
|
|
if not run_ids:
|
|
raise MlflowException(
|
|
message="GetMetricHistoryBulk request must specify at least one run_id.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
if len(run_ids) > MAX_RUN_IDS_PER_REQUEST:
|
|
raise MlflowException(
|
|
message=(
|
|
f"GetMetricHistoryBulk request cannot specify more than {MAX_RUN_IDS_PER_REQUEST}"
|
|
f" run_ids. Received {len(run_ids)} run_ids."
|
|
),
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
metric_key = request.args.get("metric_key")
|
|
if metric_key is None:
|
|
raise MlflowException(
|
|
message="GetMetricHistoryBulk request must specify a metric_key.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
max_results = int(request.args.get("max_results", MAX_HISTORY_RESULTS))
|
|
max_results = min(max_results, MAX_HISTORY_RESULTS)
|
|
|
|
store = _get_tracking_store()
|
|
|
|
def _default_history_bulk_impl():
|
|
metrics_with_run_ids = []
|
|
for run_id in sorted(run_ids):
|
|
metrics_for_run = sorted(
|
|
store.get_metric_history(
|
|
run_id=run_id,
|
|
metric_key=metric_key,
|
|
max_results=max_results,
|
|
),
|
|
key=lambda metric: (metric.timestamp, metric.step, metric.value),
|
|
)
|
|
metrics_with_run_ids.extend([
|
|
{
|
|
"key": metric.key,
|
|
"value": metric.value,
|
|
"timestamp": metric.timestamp,
|
|
"step": metric.step,
|
|
"run_id": run_id,
|
|
}
|
|
for metric in metrics_for_run
|
|
])
|
|
return metrics_with_run_ids
|
|
|
|
if hasattr(store, "get_metric_history_bulk"):
|
|
metrics_with_run_ids = [
|
|
metric.to_dict()
|
|
for metric in store.get_metric_history_bulk(
|
|
run_ids=run_ids,
|
|
metric_key=metric_key,
|
|
max_results=max_results,
|
|
)
|
|
]
|
|
else:
|
|
metrics_with_run_ids = _default_history_bulk_impl()
|
|
|
|
return {
|
|
"metrics": metrics_with_run_ids[:max_results],
|
|
}
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def get_metric_history_bulk_interval_handler():
|
|
request_message = _get_request_message(
|
|
GetMetricHistoryBulkInterval(),
|
|
schema={
|
|
"run_ids": [
|
|
_assert_required,
|
|
_assert_array,
|
|
_assert_item_type_string,
|
|
lambda x: _assert_less_than_or_equal(
|
|
len(x),
|
|
MAX_RUNS_GET_METRIC_HISTORY_BULK,
|
|
message=f"GetMetricHistoryBulkInterval request must specify at most "
|
|
f"{MAX_RUNS_GET_METRIC_HISTORY_BULK} run_ids. Received {len(x)} run_ids.",
|
|
),
|
|
],
|
|
"metric_key": [_assert_required, _assert_string],
|
|
"start_step": [_assert_intlike],
|
|
"end_step": [_assert_intlike],
|
|
"max_results": [
|
|
_assert_intlike,
|
|
lambda x: _assert_intlike_within_range(
|
|
int(x),
|
|
1,
|
|
MAX_RESULTS_PER_RUN,
|
|
message=f"max_results must be between 1 and {MAX_RESULTS_PER_RUN}.",
|
|
),
|
|
],
|
|
},
|
|
)
|
|
response_message = get_metric_history_bulk_interval_impl(request_message)
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
def get_metric_history_bulk_interval_impl(request_message):
|
|
args = request.args
|
|
run_ids = request_message.run_ids
|
|
metric_key = request_message.metric_key
|
|
max_results = int(args.get("max_results", MAX_RESULTS_PER_RUN))
|
|
start_step = args.get("start_step")
|
|
end_step = args.get("end_step")
|
|
if start_step is not None and end_step is not None:
|
|
start_step = int(start_step)
|
|
end_step = int(end_step)
|
|
if start_step > end_step:
|
|
raise MlflowException.invalid_parameter_value(
|
|
"end_step must be greater than start_step. "
|
|
f"Found start_step={start_step} and end_step={end_step}."
|
|
)
|
|
elif start_step is not None or end_step is not None:
|
|
raise MlflowException.invalid_parameter_value(
|
|
"If either start step or end step are specified, both must be specified."
|
|
)
|
|
|
|
store = _get_tracking_store()
|
|
metrics_with_run_ids = store.get_metric_history_bulk_interval(
|
|
run_ids=run_ids,
|
|
metric_key=metric_key,
|
|
max_results=max_results,
|
|
start_step=start_step,
|
|
end_step=end_step,
|
|
)
|
|
|
|
response_message = GetMetricHistoryBulkInterval.Response()
|
|
response_message.metrics.extend([m.to_proto() for m in metrics_with_run_ids])
|
|
return response_message
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _search_datasets_handler():
|
|
request_message = _get_request_message(
|
|
SearchDatasets(),
|
|
)
|
|
response_message = search_datasets_impl(request_message)
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
def search_datasets_impl(request_message):
|
|
MAX_EXPERIMENT_IDS_PER_REQUEST = 20
|
|
_validate_content_type(request, ["application/json"])
|
|
experiment_ids = request_message.experiment_ids or []
|
|
if not experiment_ids:
|
|
raise MlflowException(
|
|
message="SearchDatasets request must specify at least one experiment_id.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
if len(experiment_ids) > MAX_EXPERIMENT_IDS_PER_REQUEST:
|
|
raise MlflowException(
|
|
message=(
|
|
f"SearchDatasets request cannot specify more than {MAX_EXPERIMENT_IDS_PER_REQUEST}"
|
|
f" experiment_ids. Received {len(experiment_ids)} experiment_ids."
|
|
),
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
store = _get_tracking_store()
|
|
|
|
if hasattr(store, "_search_datasets"):
|
|
response_message = SearchDatasets.Response()
|
|
response_message.dataset_summaries.extend([
|
|
summary.to_proto() for summary in store._search_datasets(experiment_ids)
|
|
])
|
|
return response_message
|
|
else:
|
|
return _not_implemented()
|
|
|
|
|
|
def _validate_gateway_path(method: str, gateway_path: str) -> None:
|
|
if not gateway_path:
|
|
raise MlflowException(
|
|
message="Deployments proxy request must specify a gateway_path.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
elif method == "GET":
|
|
if gateway_path.strip("/") != "api/2.0/endpoints":
|
|
raise MlflowException(
|
|
message=f"Invalid gateway_path: {gateway_path} for method: {method}",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
elif method == "POST":
|
|
# For POST, gateway_path must be in the form of "gateway/{name}/invocations"
|
|
if not re.fullmatch(r"gateway/[^/]+/invocations", gateway_path.strip("/")):
|
|
raise MlflowException(
|
|
message=f"Invalid gateway_path: {gateway_path} for method: {method}",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def gateway_proxy_handler():
|
|
target_uri = MLFLOW_DEPLOYMENTS_TARGET.get()
|
|
if not target_uri:
|
|
# Pretend an empty gateway service is running
|
|
return {"endpoints": []}
|
|
|
|
args = request.args if request.method == "GET" else request.json
|
|
gateway_path = args.get("gateway_path")
|
|
_validate_gateway_path(request.method, gateway_path)
|
|
json_data = args.get("json_data", None)
|
|
response = requests.request(request.method, f"{target_uri}/{gateway_path}", json=json_data)
|
|
if response.status_code == 200:
|
|
return response.json()
|
|
else:
|
|
raise MlflowException(
|
|
message=f"Deployments proxy request failed with error code {response.status_code}. "
|
|
f"Error message: {response.text}",
|
|
error_code=response.status_code,
|
|
)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def create_promptlab_run_handler():
|
|
def assert_arg_exists(arg_name, arg):
|
|
if not arg:
|
|
raise MlflowException(
|
|
message=f"CreatePromptlabRun request must specify {arg_name}.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
_validate_content_type(request, ["application/json"])
|
|
|
|
args = request.json
|
|
experiment_id = args.get("experiment_id")
|
|
assert_arg_exists("experiment_id", experiment_id)
|
|
run_name = args.get("run_name", None)
|
|
tags = args.get("tags", [])
|
|
prompt_template = args.get("prompt_template")
|
|
assert_arg_exists("prompt_template", prompt_template)
|
|
raw_prompt_parameters = args.get("prompt_parameters")
|
|
assert_arg_exists("prompt_parameters", raw_prompt_parameters)
|
|
prompt_parameters = [
|
|
Param(param.get("key"), param.get("value")) for param in args.get("prompt_parameters")
|
|
]
|
|
model_route = args.get("model_route")
|
|
assert_arg_exists("model_route", model_route)
|
|
raw_model_parameters = args.get("model_parameters", [])
|
|
model_parameters = [
|
|
Param(param.get("key"), param.get("value")) for param in raw_model_parameters
|
|
]
|
|
model_input = args.get("model_input")
|
|
assert_arg_exists("model_input", model_input)
|
|
model_output = args.get("model_output", None)
|
|
raw_model_output_parameters = args.get("model_output_parameters", [])
|
|
model_output_parameters = [
|
|
Param(param.get("key"), param.get("value")) for param in raw_model_output_parameters
|
|
]
|
|
mlflow_version = args.get("mlflow_version")
|
|
assert_arg_exists("mlflow_version", mlflow_version)
|
|
user_id = args.get("user_id", "unknown")
|
|
|
|
# use current time if not provided
|
|
start_time = args.get("start_time", int(time.time() * 1000))
|
|
|
|
store = _get_tracking_store()
|
|
|
|
run = _create_promptlab_run_impl(
|
|
store,
|
|
experiment_id=experiment_id,
|
|
run_name=run_name,
|
|
tags=tags,
|
|
prompt_template=prompt_template,
|
|
prompt_parameters=prompt_parameters,
|
|
model_route=model_route,
|
|
model_parameters=model_parameters,
|
|
model_input=model_input,
|
|
model_output=model_output,
|
|
model_output_parameters=model_output_parameters,
|
|
mlflow_version=mlflow_version,
|
|
user_id=user_id,
|
|
start_time=start_time,
|
|
)
|
|
response_message = CreateRun.Response()
|
|
response_message.run.MergeFrom(run.to_proto())
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def upload_artifact_handler():
|
|
args = request.args
|
|
run_uuid = args.get("run_uuid")
|
|
if not run_uuid:
|
|
raise MlflowException(
|
|
message="Request must specify run_uuid.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
path = args.get("path")
|
|
if not path:
|
|
raise MlflowException(
|
|
message="Request must specify path.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
path = validate_path_is_safe(path)
|
|
|
|
if request.content_length and request.content_length > 10 * 1024 * 1024:
|
|
raise MlflowException(
|
|
message="Artifact size is too large. Max size is 10MB.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
data = request.data
|
|
if not data:
|
|
raise MlflowException(
|
|
message="Request must specify data.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
if len(data) > 10 * 1024 * 1024:
|
|
raise MlflowException(
|
|
message="Artifact size is too large. Max size is 10MB.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
run = _get_tracking_store().get_run(run_uuid)
|
|
artifact_dir = run.info.artifact_uri
|
|
|
|
basename = posixpath.basename(path)
|
|
dirname = posixpath.dirname(path)
|
|
|
|
def _log_artifact_to_repo(file, run, dirname, artifact_dir):
|
|
if _is_servable_proxied_run_artifact_root(run.info.artifact_uri):
|
|
artifact_repo = _get_artifact_repo_mlflow_artifacts()
|
|
# Use posixpath.join since these are logical artifact paths (not local filesystem paths)
|
|
# that should always use forward slashes regardless of the platform.
|
|
path_to_log = (
|
|
posixpath.join(run.info.experiment_id, run.info.run_id, "artifacts", dirname)
|
|
if dirname
|
|
else posixpath.join(run.info.experiment_id, run.info.run_id, "artifacts")
|
|
)
|
|
path_to_log = _get_workspace_scoped_repo_path_if_enabled(path_to_log)
|
|
else:
|
|
artifact_repo = get_artifact_repository(artifact_dir)
|
|
path_to_log = dirname
|
|
|
|
artifact_repo.log_artifact(file, path_to_log)
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
dir_path = os.path.join(tmpdir, dirname) if dirname else tmpdir
|
|
file_path = os.path.join(dir_path, basename)
|
|
|
|
os.makedirs(dir_path, exist_ok=True)
|
|
|
|
with open(file_path, "wb") as f:
|
|
f.write(data)
|
|
|
|
_log_artifact_to_repo(file_path, run, dirname, artifact_dir)
|
|
|
|
return Response(mimetype="application/json")
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _search_experiments():
|
|
request_message = _get_request_message(
|
|
SearchExperiments(),
|
|
schema={
|
|
"view_type": [_assert_intlike],
|
|
"max_results": [_assert_intlike],
|
|
"order_by": [_assert_array],
|
|
"filter": [_assert_string],
|
|
"page_token": [_assert_string],
|
|
},
|
|
)
|
|
|
|
experiment_entities = _get_tracking_store().search_experiments(
|
|
view_type=request_message.view_type,
|
|
max_results=request_message.max_results,
|
|
order_by=request_message.order_by,
|
|
filter_string=request_message.filter,
|
|
page_token=request_message.page_token or None,
|
|
)
|
|
response_message = SearchExperiments.Response()
|
|
response_message.experiments.extend([e.to_proto() for e in experiment_entities])
|
|
if experiment_entities.token:
|
|
response_message.next_page_token = experiment_entities.token
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def _get_artifact_repo(run):
|
|
return get_artifact_repository(run.info.artifact_uri)
|
|
|
|
|
|
_HANDLER_BLOCKED_TRACE_TAGS = frozenset({
|
|
MLFLOW_TRACE_SPANS_LOCATION,
|
|
MLFLOW_TRACE_ARCHIVE_LOCATION,
|
|
MLFLOW_TRACE_ARCHIVAL_FAILURE,
|
|
})
|
|
_HANDLER_TRACE_TAGS_MUTABLE_ON_DELETE = frozenset({MLFLOW_TRACE_ARCHIVAL_FAILURE})
|
|
|
|
|
|
def _validate_trace_tag_handler_mutation(key: str, operation: str) -> None:
|
|
# `mlflow.trace.archivalFailure` is system-managed for writes, but deleting it is the
|
|
# supported way to clear a terminal archival failure and allow a later retry.
|
|
if key in _HANDLER_BLOCKED_TRACE_TAGS and not (
|
|
operation == "deleted" and key in _HANDLER_TRACE_TAGS_MUTABLE_ON_DELETE
|
|
):
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Tag '{key}' is immutable and cannot be {operation} on a trace."
|
|
)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _log_batch():
|
|
def _assert_metrics_fields_present(metrics):
|
|
for idx, m in enumerate(metrics):
|
|
_assert_required(m.get("key"), path=f"metrics[{idx}].key")
|
|
_assert_required(m.get("value"), path=f"metrics[{idx}].value")
|
|
_assert_required(m.get("timestamp"), path=f"metrics[{idx}].timestamp")
|
|
|
|
def _assert_params_fields_present(params):
|
|
for idx, param in enumerate(params):
|
|
_assert_required(param.get("key"), path=f"params[{idx}].key")
|
|
|
|
def _assert_tags_fields_present(tags):
|
|
for idx, tag in enumerate(tags):
|
|
_assert_required(tag.get("key"), path=f"tags[{idx}].key")
|
|
|
|
_validate_batch_log_api_req(_get_request_json())
|
|
request_message = _get_request_message(
|
|
LogBatch(),
|
|
schema={
|
|
"run_id": [_assert_string, _assert_required],
|
|
"metrics": [_assert_array, _assert_metrics_fields_present],
|
|
"params": [_assert_array, _assert_params_fields_present],
|
|
"tags": [_assert_array, _assert_tags_fields_present],
|
|
},
|
|
)
|
|
metrics = [Metric.from_proto(proto_metric) for proto_metric in request_message.metrics]
|
|
params = [Param.from_proto(proto_param) for proto_param in request_message.params]
|
|
tags = [RunTag.from_proto(proto_tag) for proto_tag in request_message.tags]
|
|
_get_tracking_store().log_batch(
|
|
run_id=request_message.run_id, metrics=metrics, params=params, tags=tags
|
|
)
|
|
response_message = LogBatch.Response()
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _log_model():
|
|
request_message = _get_request_message(
|
|
LogModel(),
|
|
schema={
|
|
"run_id": [_assert_string, _assert_required],
|
|
"model_json": [_assert_string, _assert_required],
|
|
},
|
|
)
|
|
try:
|
|
model = json.loads(request_message.model_json)
|
|
except Exception:
|
|
raise MlflowException(
|
|
f"Malformed model info. \n {request_message.model_json} \n is not a valid JSON.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
missing_fields = {"artifact_path", "flavors", "utc_time_created", "run_id"} - set(model.keys())
|
|
|
|
if missing_fields:
|
|
raise MlflowException(
|
|
f"Model json is missing mandatory fields: {missing_fields}",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
_get_tracking_store().record_logged_model(
|
|
run_id=request_message.run_id, mlflow_model=Model.from_dict(model)
|
|
)
|
|
response_message = LogModel.Response()
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
def _wrap_response(response_message):
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
# Model Registry APIs
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _create_registered_model():
|
|
request_message = _get_request_message(
|
|
CreateRegisteredModel(),
|
|
schema={
|
|
"name": [_assert_string, _assert_required],
|
|
"tags": [_assert_array],
|
|
"description": [_assert_string],
|
|
},
|
|
)
|
|
store = _get_model_registry_store()
|
|
registered_model = store.create_registered_model(
|
|
name=request_message.name,
|
|
tags=request_message.tags,
|
|
description=request_message.description,
|
|
)
|
|
response_message = CreateRegisteredModel.Response(registered_model=registered_model.to_proto())
|
|
|
|
# Determine if this is a prompt based on the tags
|
|
if _is_prompt_request(request_message):
|
|
# Send prompt creation webhook
|
|
deliver_webhook(
|
|
event=WebhookEvent(WebhookEntity.PROMPT, WebhookAction.CREATED),
|
|
payload=PromptCreatedPayload(
|
|
name=request_message.name,
|
|
tags={
|
|
t.key: t.value
|
|
for t in request_message.tags
|
|
if t.key not in {IS_PROMPT_TAG_KEY, PROMPT_TYPE_TAG_KEY}
|
|
},
|
|
description=request_message.description,
|
|
),
|
|
store=store,
|
|
)
|
|
else:
|
|
# Send regular model creation webhook
|
|
deliver_webhook(
|
|
event=WebhookEvent(WebhookEntity.REGISTERED_MODEL, WebhookAction.CREATED),
|
|
payload=RegisteredModelCreatedPayload(
|
|
name=request_message.name,
|
|
tags={t.key: t.value for t in request_message.tags},
|
|
description=request_message.description,
|
|
),
|
|
store=store,
|
|
)
|
|
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_registered_model():
|
|
request_message = _get_request_message(
|
|
GetRegisteredModel(), schema={"name": [_assert_string, _assert_required]}
|
|
)
|
|
registered_model = _get_model_registry_store().get_registered_model(name=request_message.name)
|
|
response_message = GetRegisteredModel.Response(registered_model=registered_model.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _update_registered_model():
|
|
request_message = _get_request_message(
|
|
UpdateRegisteredModel(),
|
|
schema={
|
|
"name": [_assert_string, _assert_required],
|
|
"description": [_assert_string],
|
|
},
|
|
)
|
|
name = request_message.name
|
|
new_description = request_message.description
|
|
registered_model = _get_model_registry_store().update_registered_model(
|
|
name=name, description=new_description
|
|
)
|
|
response_message = UpdateRegisteredModel.Response(registered_model=registered_model.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _rename_registered_model():
|
|
request_message = _get_request_message(
|
|
RenameRegisteredModel(),
|
|
schema={
|
|
"name": [_assert_string, _assert_required],
|
|
"new_name": [_assert_string, _assert_required],
|
|
},
|
|
)
|
|
name = request_message.name
|
|
new_name = request_message.new_name
|
|
registered_model = _get_model_registry_store().rename_registered_model(
|
|
name=name, new_name=new_name
|
|
)
|
|
response_message = RenameRegisteredModel.Response(registered_model=registered_model.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_registered_model():
|
|
request_message = _get_request_message(
|
|
DeleteRegisteredModel(), schema={"name": [_assert_string, _assert_required]}
|
|
)
|
|
_get_model_registry_store().delete_registered_model(name=request_message.name)
|
|
return _wrap_response(DeleteRegisteredModel.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _search_registered_models():
|
|
request_message = _get_request_message(
|
|
SearchRegisteredModels(),
|
|
schema={
|
|
"filter": [_assert_string],
|
|
"max_results": [
|
|
_assert_intlike,
|
|
lambda x: _assert_less_than_or_equal(int(x), 1000),
|
|
],
|
|
"order_by": [_assert_array, _assert_item_type_string],
|
|
"page_token": [_assert_string],
|
|
},
|
|
)
|
|
store = _get_model_registry_store()
|
|
registered_models = store.search_registered_models(
|
|
filter_string=request_message.filter,
|
|
max_results=request_message.max_results,
|
|
order_by=request_message.order_by,
|
|
page_token=request_message.page_token or None,
|
|
)
|
|
response_message = SearchRegisteredModels.Response()
|
|
response_message.registered_models.extend([e.to_proto() for e in registered_models])
|
|
if registered_models.token:
|
|
response_message.next_page_token = registered_models.token
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_latest_versions():
|
|
request_message = _get_request_message(
|
|
GetLatestVersions(),
|
|
schema={
|
|
"name": [_assert_string, _assert_required],
|
|
"stages": [_assert_array, _assert_item_type_string],
|
|
},
|
|
)
|
|
latest_versions = _get_model_registry_store().get_latest_versions(
|
|
name=request_message.name, stages=request_message.stages
|
|
)
|
|
response_message = GetLatestVersions.Response()
|
|
response_message.model_versions.extend([e.to_proto() for e in latest_versions])
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _set_registered_model_tag():
|
|
request_message = _get_request_message(
|
|
SetRegisteredModelTag(),
|
|
schema={
|
|
"name": [_assert_string, _assert_required],
|
|
"key": [_assert_string, _assert_required],
|
|
"value": [_assert_string],
|
|
},
|
|
)
|
|
tag = RegisteredModelTag(key=request_message.key, value=request_message.value)
|
|
store = _get_model_registry_store()
|
|
store.set_registered_model_tag(name=request_message.name, tag=tag)
|
|
|
|
if _is_prompt(request_message.name):
|
|
# Send prompt tag set webhook
|
|
deliver_webhook(
|
|
event=WebhookEvent(WebhookEntity.PROMPT_TAG, WebhookAction.SET),
|
|
payload=PromptTagSetPayload(
|
|
name=request_message.name,
|
|
key=request_message.key,
|
|
value=request_message.value,
|
|
),
|
|
store=store,
|
|
)
|
|
|
|
return _wrap_response(SetRegisteredModelTag.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_registered_model_tag():
|
|
request_message = _get_request_message(
|
|
DeleteRegisteredModelTag(),
|
|
schema={
|
|
"name": [_assert_string, _assert_required],
|
|
"key": [_assert_string, _assert_required],
|
|
},
|
|
)
|
|
store = _get_model_registry_store()
|
|
store.delete_registered_model_tag(name=request_message.name, key=request_message.key)
|
|
|
|
if _is_prompt(request_message.name):
|
|
# Send prompt tag deleted webhook
|
|
deliver_webhook(
|
|
event=WebhookEvent(WebhookEntity.PROMPT_TAG, WebhookAction.DELETED),
|
|
payload=PromptTagDeletedPayload(
|
|
name=request_message.name,
|
|
key=request_message.key,
|
|
),
|
|
store=store,
|
|
)
|
|
|
|
return _wrap_response(DeleteRegisteredModelTag.Response())
|
|
|
|
|
|
def _validate_non_local_source_contains_relative_paths(source: str):
|
|
"""
|
|
Validation check to ensure that sources that are provided that conform to the schemes:
|
|
http, https, or mlflow-artifacts do not contain relative path designations that are intended
|
|
to access local file system paths on the tracking server.
|
|
|
|
Example paths that this validation function is intended to find and raise an Exception if
|
|
passed:
|
|
"mlflow-artifacts://host:port/../../../../"
|
|
"http://host:port/api/2.0/mlflow-artifacts/artifacts/../../../../"
|
|
"https://host:port/api/2.0/mlflow-artifacts/artifacts/../../../../"
|
|
"/models/artifacts/../../../"
|
|
"s3:/my_bucket/models/path/../../other/path"
|
|
"file://path/to/../../../../some/where/you/should/not/be"
|
|
"mlflow-artifacts://host:port/..%2f..%2f..%2f..%2f"
|
|
"http://host:port/api/2.0/mlflow-artifacts/artifacts%00"
|
|
"""
|
|
invalid_source_error_message = (
|
|
f"Invalid model version source: '{source}'. If supplying a source as an http, https, "
|
|
"local file path, ftp, objectstore, or mlflow-artifacts uri, an absolute path must be "
|
|
"provided without relative path references present. "
|
|
"Please provide an absolute path."
|
|
)
|
|
|
|
while (unquoted := urllib.parse.unquote_plus(source)) != source:
|
|
source = unquoted
|
|
source_path = re.sub(r"/+", "/", urllib.parse.urlparse(source).path.rstrip("/"))
|
|
if "\x00" in source_path or any(p == ".." for p in source.split("/")):
|
|
raise MlflowException(invalid_source_error_message, INVALID_PARAMETER_VALUE)
|
|
resolved_source = pathlib.Path(source_path).resolve().as_posix()
|
|
# NB: drive split is specifically for Windows since WindowsPath.resolve() will append the
|
|
# drive path of the pwd to a given path. We don't care about the drive here, though.
|
|
_, resolved_path = os.path.splitdrive(resolved_source)
|
|
|
|
if resolved_path != source_path:
|
|
raise MlflowException(invalid_source_error_message, INVALID_PARAMETER_VALUE)
|
|
|
|
|
|
def _validate_source_run(source: str, run_id: str) -> None:
|
|
if is_local_uri(source):
|
|
if run_id:
|
|
store = _get_tracking_store()
|
|
run = store.get_run(run_id)
|
|
source = pathlib.Path(local_file_uri_to_path(source)).resolve()
|
|
if is_local_uri(run.info.artifact_uri):
|
|
run_artifact_dir = pathlib.Path(
|
|
local_file_uri_to_path(run.info.artifact_uri)
|
|
).resolve()
|
|
if run_artifact_dir in [source, *source.parents]:
|
|
return
|
|
|
|
raise MlflowException(
|
|
f"Invalid model version source: '{source}'. To use a local path as a model version "
|
|
"source, 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.",
|
|
INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
# Checks if relative paths are present in the source (a security threat). If any are present,
|
|
# raises an Exception.
|
|
_validate_non_local_source_contains_relative_paths(source)
|
|
|
|
|
|
def _validate_source_model(source: str, model_id: str) -> None:
|
|
if is_local_uri(source):
|
|
if model_id:
|
|
store = _get_tracking_store()
|
|
model = store.get_logged_model(model_id)
|
|
source = pathlib.Path(local_file_uri_to_path(source)).resolve()
|
|
if is_local_uri(model.artifact_location):
|
|
run_artifact_dir = pathlib.Path(
|
|
local_file_uri_to_path(model.artifact_location)
|
|
).resolve()
|
|
if run_artifact_dir in [source, *source.parents]:
|
|
return
|
|
|
|
raise MlflowException(
|
|
f"Invalid model version source: '{source}'. To use a local path as a model version "
|
|
"source, the model_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 model_id.",
|
|
INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
# Checks if relative paths are present in the source (a security threat). If any are present,
|
|
# raises an Exception.
|
|
_validate_non_local_source_contains_relative_paths(source)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _create_model_version():
|
|
request_message = _get_request_message(
|
|
CreateModelVersion(),
|
|
schema={
|
|
"name": [_assert_string, _assert_required],
|
|
"source": [_assert_string, _assert_required],
|
|
"run_id": [_assert_string],
|
|
"tags": [_assert_array],
|
|
"run_link": [_assert_string],
|
|
"description": [_assert_string],
|
|
"model_id": [_assert_string],
|
|
},
|
|
)
|
|
|
|
if request_message.source and (
|
|
regex := MLFLOW_CREATE_MODEL_VERSION_SOURCE_VALIDATION_REGEX.get()
|
|
):
|
|
if not re.search(regex, request_message.source):
|
|
raise MlflowException(
|
|
f"Invalid model version source: '{request_message.source}'.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
is_prompt = _is_prompt_request(request_message)
|
|
if is_prompt:
|
|
# Prompt sources must not point to local filesystem paths.
|
|
# Block file:// URIs and absolute paths (e.g. /etc/passwd) but allow
|
|
# the legitimate schemeless placeholder sources used internally
|
|
# (e.g. "prompt-template", "dummy-source").
|
|
source = request_message.source
|
|
parsed = urllib.parse.urlparse(source)
|
|
if parsed.scheme == "file" or (parsed.scheme == "" and source.startswith("/")):
|
|
raise MlflowException(
|
|
f"Invalid prompt source: '{source}'. "
|
|
"Local source paths are not allowed for prompts.",
|
|
INVALID_PARAMETER_VALUE,
|
|
)
|
|
# Only validate traversal for sources with a URL scheme (http, https, etc.)
|
|
if parsed.scheme:
|
|
_validate_non_local_source_contains_relative_paths(source)
|
|
else:
|
|
if request_message.model_id:
|
|
_validate_source_model(request_message.source, request_message.model_id)
|
|
else:
|
|
_validate_source_run(request_message.source, request_message.run_id)
|
|
|
|
store = _get_model_registry_store()
|
|
model_version = store.create_model_version(
|
|
name=request_message.name,
|
|
source=request_message.source,
|
|
run_id=request_message.run_id,
|
|
run_link=request_message.run_link,
|
|
tags=request_message.tags,
|
|
description=request_message.description,
|
|
model_id=request_message.model_id,
|
|
)
|
|
if not is_prompt and request_message.model_id:
|
|
tracking_store = _get_tracking_store()
|
|
tracking_store.set_model_versions_tags(
|
|
name=request_message.name,
|
|
version=model_version.version,
|
|
model_id=request_message.model_id,
|
|
)
|
|
response_message = CreateModelVersion.Response(model_version=model_version.to_proto())
|
|
|
|
if is_prompt:
|
|
# Convert tags to dict and extract template text efficiently
|
|
tags_dict = {t.key: t.value for t in request_message.tags}
|
|
template_text = tags_dict.pop(PROMPT_TEXT_TAG_KEY, None)
|
|
# Remove internal prompt identification and type tags
|
|
tags_dict.pop(IS_PROMPT_TAG_KEY, None)
|
|
tags_dict.pop(PROMPT_TYPE_TAG_KEY, None)
|
|
|
|
# Send prompt version creation webhook
|
|
deliver_webhook(
|
|
event=WebhookEvent(WebhookEntity.PROMPT_VERSION, WebhookAction.CREATED),
|
|
payload=PromptVersionCreatedPayload(
|
|
name=request_message.name,
|
|
version=str(model_version.version),
|
|
template=template_text,
|
|
tags=tags_dict,
|
|
description=request_message.description or None,
|
|
),
|
|
store=store,
|
|
)
|
|
else:
|
|
# Send regular model version creation webhook
|
|
deliver_webhook(
|
|
event=WebhookEvent(WebhookEntity.MODEL_VERSION, WebhookAction.CREATED),
|
|
payload=ModelVersionCreatedPayload(
|
|
name=request_message.name,
|
|
version=str(model_version.version),
|
|
source=request_message.source,
|
|
run_id=request_message.run_id or None,
|
|
tags={t.key: t.value for t in request_message.tags},
|
|
description=request_message.description or None,
|
|
),
|
|
store=store,
|
|
)
|
|
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
def _is_prompt_request(request_message):
|
|
return any(tag.key == IS_PROMPT_TAG_KEY for tag in request_message.tags)
|
|
|
|
|
|
def _is_prompt(name: str) -> bool:
|
|
rm = _get_model_registry_store().get_registered_model(name=name)
|
|
return rm._is_prompt()
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def get_model_version_artifact_handler():
|
|
name = request.args.get("name")
|
|
version = request.args.get("version")
|
|
path = request.args["path"]
|
|
path = validate_path_is_safe(path)
|
|
artifact_uri = _get_model_registry_store().get_model_version_download_uri(name, version)
|
|
if _is_servable_proxied_run_artifact_root(artifact_uri):
|
|
artifact_repo = _get_artifact_repo_mlflow_artifacts()
|
|
artifact_path = _get_proxied_run_artifact_destination_path(
|
|
proxied_artifact_root=artifact_uri,
|
|
relative_path=path,
|
|
)
|
|
artifact_path = _get_workspace_scoped_repo_path_if_enabled(artifact_path)
|
|
else:
|
|
artifact_repo = get_artifact_repository(artifact_uri)
|
|
artifact_path = path
|
|
|
|
return _send_artifact(artifact_repo, artifact_path)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_model_version():
|
|
request_message = _get_request_message(
|
|
GetModelVersion(),
|
|
schema={
|
|
"name": [_assert_string, _assert_required],
|
|
"version": [_assert_string, _assert_required],
|
|
},
|
|
)
|
|
model_version = _get_model_registry_store().get_model_version(
|
|
name=request_message.name, version=request_message.version
|
|
)
|
|
response_proto = model_version.to_proto()
|
|
response_message = GetModelVersion.Response(model_version=response_proto)
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _update_model_version():
|
|
request_message = _get_request_message(
|
|
UpdateModelVersion(),
|
|
schema={
|
|
"name": [_assert_string, _assert_required],
|
|
"version": [_assert_string, _assert_required],
|
|
"description": [_assert_string],
|
|
},
|
|
)
|
|
new_description = None
|
|
if request_message.HasField("description"):
|
|
new_description = request_message.description
|
|
model_version = _get_model_registry_store().update_model_version(
|
|
name=request_message.name,
|
|
version=request_message.version,
|
|
description=new_description,
|
|
)
|
|
return _wrap_response(UpdateModelVersion.Response(model_version=model_version.to_proto()))
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _transition_stage():
|
|
request_message = _get_request_message(
|
|
TransitionModelVersionStage(),
|
|
schema={
|
|
"name": [_assert_string, _assert_required],
|
|
"version": [_assert_string, _assert_required],
|
|
"stage": [_assert_string, _assert_required],
|
|
"archive_existing_versions": [_assert_bool],
|
|
},
|
|
)
|
|
model_version = _get_model_registry_store().transition_model_version_stage(
|
|
name=request_message.name,
|
|
version=request_message.version,
|
|
stage=request_message.stage,
|
|
archive_existing_versions=request_message.archive_existing_versions,
|
|
)
|
|
return _wrap_response(
|
|
TransitionModelVersionStage.Response(model_version=model_version.to_proto())
|
|
)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_model_version():
|
|
request_message = _get_request_message(
|
|
DeleteModelVersion(),
|
|
schema={
|
|
"name": [_assert_string, _assert_required],
|
|
"version": [_assert_string, _assert_required],
|
|
},
|
|
)
|
|
_get_model_registry_store().delete_model_version(
|
|
name=request_message.name, version=request_message.version
|
|
)
|
|
return _wrap_response(DeleteModelVersion.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_model_version_download_uri():
|
|
request_message = _get_request_message(GetModelVersionDownloadUri())
|
|
download_uri = _get_model_registry_store().get_model_version_download_uri(
|
|
name=request_message.name, version=request_message.version
|
|
)
|
|
response_message = GetModelVersionDownloadUri.Response(artifact_uri=download_uri)
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _search_model_versions():
|
|
request_message = _get_request_message(
|
|
SearchModelVersions(),
|
|
schema={
|
|
"filter": [_assert_string],
|
|
"max_results": [
|
|
_assert_intlike,
|
|
lambda x: _assert_less_than_or_equal(int(x), 200_000),
|
|
],
|
|
"order_by": [_assert_array, _assert_item_type_string],
|
|
"page_token": [_assert_string],
|
|
},
|
|
)
|
|
response_message = search_model_versions_impl(request_message)
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
def search_model_versions_impl(request_message):
|
|
store = _get_model_registry_store()
|
|
model_versions = store.search_model_versions(
|
|
filter_string=request_message.filter,
|
|
max_results=request_message.max_results,
|
|
order_by=request_message.order_by,
|
|
page_token=request_message.page_token or None,
|
|
)
|
|
response_message = SearchModelVersions.Response()
|
|
response_message.model_versions.extend([e.to_proto() for e in model_versions])
|
|
if model_versions.token:
|
|
response_message.next_page_token = model_versions.token
|
|
return response_message
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _set_model_version_tag():
|
|
request_message = _get_request_message(
|
|
SetModelVersionTag(),
|
|
schema={
|
|
"name": [_assert_string, _assert_required],
|
|
"version": [_assert_string, _assert_required],
|
|
"key": [_assert_string, _assert_required],
|
|
"value": [_assert_string],
|
|
},
|
|
)
|
|
tag = ModelVersionTag(key=request_message.key, value=request_message.value)
|
|
store = _get_model_registry_store()
|
|
store.set_model_version_tag(name=request_message.name, version=request_message.version, tag=tag)
|
|
|
|
if _is_prompt(request_message.name):
|
|
# Send prompt version tag set webhook
|
|
deliver_webhook(
|
|
event=WebhookEvent(WebhookEntity.PROMPT_VERSION_TAG, WebhookAction.SET),
|
|
payload=PromptVersionTagSetPayload(
|
|
name=request_message.name,
|
|
version=request_message.version,
|
|
key=request_message.key,
|
|
value=request_message.value,
|
|
),
|
|
store=store,
|
|
)
|
|
else:
|
|
# Send regular model version tag set webhook
|
|
deliver_webhook(
|
|
event=WebhookEvent(WebhookEntity.MODEL_VERSION_TAG, WebhookAction.SET),
|
|
payload=ModelVersionTagSetPayload(
|
|
name=request_message.name,
|
|
version=request_message.version,
|
|
key=request_message.key,
|
|
value=request_message.value,
|
|
),
|
|
store=store,
|
|
)
|
|
|
|
return _wrap_response(SetModelVersionTag.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_model_version_tag():
|
|
request_message = _get_request_message(
|
|
DeleteModelVersionTag(),
|
|
schema={
|
|
"name": [_assert_string, _assert_required],
|
|
"version": [_assert_string, _assert_required],
|
|
"key": [_assert_string, _assert_required],
|
|
},
|
|
)
|
|
store = _get_model_registry_store()
|
|
store.delete_model_version_tag(
|
|
name=request_message.name,
|
|
version=request_message.version,
|
|
key=request_message.key,
|
|
)
|
|
|
|
if _is_prompt(request_message.name):
|
|
# Send prompt version tag deleted webhook
|
|
deliver_webhook(
|
|
event=WebhookEvent(WebhookEntity.PROMPT_VERSION_TAG, WebhookAction.DELETED),
|
|
payload=PromptVersionTagDeletedPayload(
|
|
name=request_message.name,
|
|
version=request_message.version,
|
|
key=request_message.key,
|
|
),
|
|
store=store,
|
|
)
|
|
else:
|
|
# Send regular model version tag deleted webhook
|
|
deliver_webhook(
|
|
event=WebhookEvent(WebhookEntity.MODEL_VERSION_TAG, WebhookAction.DELETED),
|
|
payload=ModelVersionTagDeletedPayload(
|
|
name=request_message.name,
|
|
version=request_message.version,
|
|
key=request_message.key,
|
|
),
|
|
store=store,
|
|
)
|
|
|
|
return _wrap_response(DeleteModelVersionTag.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _set_registered_model_alias():
|
|
request_message = _get_request_message(
|
|
SetRegisteredModelAlias(),
|
|
schema={
|
|
"name": [_assert_string, _assert_required],
|
|
"alias": [_assert_string, _assert_required],
|
|
"version": [_assert_string, _assert_required],
|
|
},
|
|
)
|
|
store = _get_model_registry_store()
|
|
store.set_registered_model_alias(
|
|
name=request_message.name,
|
|
alias=request_message.alias,
|
|
version=request_message.version,
|
|
)
|
|
|
|
if _is_prompt(request_message.name):
|
|
# Send prompt alias created webhook
|
|
deliver_webhook(
|
|
event=WebhookEvent(WebhookEntity.PROMPT_ALIAS, WebhookAction.CREATED),
|
|
payload=PromptAliasCreatedPayload(
|
|
name=request_message.name,
|
|
alias=request_message.alias,
|
|
version=request_message.version,
|
|
),
|
|
store=store,
|
|
)
|
|
else:
|
|
# Send regular model version alias created webhook
|
|
deliver_webhook(
|
|
event=WebhookEvent(WebhookEntity.MODEL_VERSION_ALIAS, WebhookAction.CREATED),
|
|
payload=ModelVersionAliasCreatedPayload(
|
|
name=request_message.name,
|
|
alias=request_message.alias,
|
|
version=request_message.version,
|
|
),
|
|
store=store,
|
|
)
|
|
|
|
return _wrap_response(SetRegisteredModelAlias.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_registered_model_alias():
|
|
request_message = _get_request_message(
|
|
DeleteRegisteredModelAlias(),
|
|
schema={
|
|
"name": [_assert_string, _assert_required],
|
|
"alias": [_assert_string, _assert_required],
|
|
},
|
|
)
|
|
store = _get_model_registry_store()
|
|
store.delete_registered_model_alias(name=request_message.name, alias=request_message.alias)
|
|
|
|
if _is_prompt(request_message.name):
|
|
# Send prompt alias deleted webhook
|
|
deliver_webhook(
|
|
event=WebhookEvent(WebhookEntity.PROMPT_ALIAS, WebhookAction.DELETED),
|
|
payload=PromptAliasDeletedPayload(
|
|
name=request_message.name,
|
|
alias=request_message.alias,
|
|
),
|
|
store=store,
|
|
)
|
|
else:
|
|
# Send regular model version alias deleted webhook
|
|
deliver_webhook(
|
|
event=WebhookEvent(WebhookEntity.MODEL_VERSION_ALIAS, WebhookAction.DELETED),
|
|
payload=ModelVersionAliasDeletedPayload(
|
|
name=request_message.name,
|
|
alias=request_message.alias,
|
|
),
|
|
store=store,
|
|
)
|
|
|
|
return _wrap_response(DeleteRegisteredModelAlias.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_model_version_by_alias():
|
|
request_message = _get_request_message(
|
|
GetModelVersionByAlias(),
|
|
schema={
|
|
"name": [_assert_string, _assert_required],
|
|
"alias": [_assert_string, _assert_required],
|
|
},
|
|
)
|
|
model_version = _get_model_registry_store().get_model_version_by_alias(
|
|
name=request_message.name, alias=request_message.alias
|
|
)
|
|
response_proto = model_version.to_proto()
|
|
response_message = GetModelVersionByAlias.Response(model_version=response_proto)
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
# Webhook APIs
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _create_webhook():
|
|
request_message = _get_request_message(
|
|
CreateWebhook(),
|
|
schema={
|
|
"name": [_assert_string, _assert_required],
|
|
"url": [_assert_string, _assert_required],
|
|
"events": [_assert_array, _assert_required],
|
|
"description": [_assert_string],
|
|
"secret": [_assert_string],
|
|
"status": [_assert_string],
|
|
},
|
|
)
|
|
|
|
webhook = _get_model_registry_store().create_webhook(
|
|
name=request_message.name,
|
|
url=request_message.url,
|
|
events=[WebhookEvent.from_proto(e) for e in request_message.events],
|
|
description=request_message.description or None,
|
|
secret=request_message.secret or None,
|
|
status=WebhookStatus.from_proto(request_message.status) if request_message.status else None,
|
|
)
|
|
response_message = CreateWebhook.Response(webhook=webhook.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _list_webhooks():
|
|
request_message = _get_request_message(
|
|
ListWebhooks(),
|
|
schema={
|
|
"max_results": [_assert_intlike],
|
|
"page_token": [_assert_string],
|
|
},
|
|
)
|
|
webhooks_page = _get_model_registry_store().list_webhooks(
|
|
max_results=request_message.max_results,
|
|
page_token=request_message.page_token or None,
|
|
)
|
|
response_message = ListWebhooks.Response(
|
|
webhooks=[w.to_proto() for w in webhooks_page],
|
|
next_page_token=webhooks_page.token,
|
|
)
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_webhook(webhook_id: str):
|
|
webhook = _get_model_registry_store().get_webhook(webhook_id=webhook_id)
|
|
response_message = GetWebhook.Response(webhook=webhook.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _update_webhook(webhook_id: str):
|
|
request_message = _get_request_message(
|
|
UpdateWebhook(),
|
|
schema={
|
|
"name": [_assert_string],
|
|
"description": [_assert_string],
|
|
"url": [_assert_string],
|
|
"events": [_assert_array],
|
|
"secret": [_assert_string],
|
|
"status": [_assert_string],
|
|
},
|
|
)
|
|
webhook = _get_model_registry_store().update_webhook(
|
|
webhook_id=webhook_id,
|
|
name=request_message.name or None,
|
|
description=request_message.description or None,
|
|
url=request_message.url or None,
|
|
events=(
|
|
[WebhookEvent.from_proto(e) for e in request_message.events]
|
|
if request_message.events
|
|
else None
|
|
),
|
|
secret=request_message.secret or None,
|
|
status=WebhookStatus.from_proto(request_message.status) if request_message.status else None,
|
|
)
|
|
response_message = UpdateWebhook.Response(webhook=webhook.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_webhook(webhook_id: str):
|
|
_get_model_registry_store().delete_webhook(webhook_id=webhook_id)
|
|
response_message = DeleteWebhook.Response()
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _test_webhook(webhook_id: str):
|
|
request_message = _get_request_message(TestWebhook())
|
|
event = (
|
|
WebhookEvent.from_proto(request_message.event)
|
|
if request_message.HasField("event")
|
|
else None
|
|
)
|
|
store = _get_model_registry_store()
|
|
webhook = store.get_webhook(webhook_id=webhook_id)
|
|
test_result = test_webhook(webhook=webhook, event=event)
|
|
response_message = TestWebhook.Response(result=test_result.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
# MLflow Artifacts APIs
|
|
|
|
|
|
def _get_workspace_scoped_repo_path_if_enabled(artifact_path: str | None) -> str | None:
|
|
"""
|
|
Normalize artifact paths for proxied (served) artifacts so they remain workspace-isolated.
|
|
|
|
When ``mlflow-artifacts`` proxying is enabled and workspaces are on, every path under the HTTP
|
|
artifact endpoint must be rooted at ``workspaces/<workspace>/...``. Direct artifact repositories
|
|
(e.g., S3, GCS, local URIs) already encode their own isolation, so they bypass this logic by
|
|
calling the underlying store directly. Only the proxied repos need to be rewritten/validated
|
|
here.
|
|
|
|
Returns:
|
|
The workspace-scoped path. May return the original path in the following cases:
|
|
- Workspaces are disabled (returns ``artifact_path`` unchanged).
|
|
- Default workspace with no path (returns ``artifact_path`` unchanged to preserve legacy
|
|
root behavior, where artifacts live at the root rather than under ``workspaces/default``).
|
|
For non-default workspaces, always returns a string (``workspaces/<workspace>/...``).
|
|
"""
|
|
if not MLFLOW_ENABLE_WORKSPACES.get():
|
|
return artifact_path
|
|
|
|
workspace = workspace_context.get_request_workspace()
|
|
if not workspace:
|
|
raise MlflowException.invalid_parameter_value(
|
|
"Active workspace is required for artifact operations. "
|
|
"Ensure X-MLFLOW-WORKSPACE is set or call mlflow.set_workspace()."
|
|
)
|
|
|
|
normalized = artifact_path.lstrip("/") if artifact_path else ""
|
|
base = posixpath.join("workspaces", workspace)
|
|
|
|
if not normalized:
|
|
# For the default workspace, preserve the legacy root behavior (no prefix),
|
|
# so root operations continue to see the existing layout.
|
|
return base if workspace != DEFAULT_WORKSPACE_NAME else artifact_path
|
|
|
|
if workspace == DEFAULT_WORKSPACE_NAME and not normalized.startswith("workspaces/"):
|
|
# Legacy default-workspace artifacts never had the workspace prefix; allow them to be served
|
|
# without rewriting as long as the path isn't trying to opt into the reserved namespace.
|
|
return artifact_path
|
|
|
|
leading_segments = normalized.split("/", 2)
|
|
if leading_segments and leading_segments[0] == "workspaces":
|
|
if len(leading_segments) == 1 or not leading_segments[1]:
|
|
raise MlflowException.invalid_parameter_value(
|
|
"Artifact paths prefixed with 'workspaces/' must include a workspace name."
|
|
)
|
|
requested_workspace = leading_segments[1]
|
|
if requested_workspace != workspace:
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Artifact path targets workspace '{requested_workspace}' "
|
|
f"but the workspace specified in the request is '{workspace}'."
|
|
)
|
|
return normalized
|
|
|
|
return posixpath.join(base, normalized)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_unless_serve_artifacts
|
|
def _download_artifact(artifact_path):
|
|
"""
|
|
A request handler for `GET /mlflow-artifacts/artifacts/<artifact_path>` to download an artifact
|
|
from `artifact_path` (a relative path from the root artifact directory).
|
|
"""
|
|
artifact_path = validate_path_is_safe(artifact_path)
|
|
artifact_path = _get_workspace_scoped_repo_path_if_enabled(artifact_path)
|
|
artifact_repo = _get_artifact_repo_mlflow_artifacts()
|
|
|
|
if (local_path := artifact_repo.get_local_path(artifact_path)) is not None:
|
|
return _create_artifact_file_response(os.path.abspath(local_path), artifact_path)
|
|
|
|
tmp_dir = tempfile.TemporaryDirectory()
|
|
try:
|
|
dst = os.path.abspath(artifact_repo.download_artifacts(artifact_path, tmp_dir.name))
|
|
|
|
# Ref: https://stackoverflow.com/a/24613980/6943581
|
|
file_handle = open(dst, "rb") # noqa: SIM115
|
|
except Exception:
|
|
tmp_dir.cleanup()
|
|
raise
|
|
|
|
def stream_and_remove_file():
|
|
while chunk := file_handle.read(ARTIFACT_STREAM_CHUNK_SIZE):
|
|
yield chunk
|
|
file_handle.close()
|
|
tmp_dir.cleanup()
|
|
|
|
file_sender_response = current_app.response_class(stream_and_remove_file())
|
|
|
|
return _response_with_file_attachment_headers(artifact_path, file_sender_response)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_unless_serve_artifacts
|
|
def _upload_artifact(artifact_path):
|
|
"""
|
|
A request handler for `PUT /mlflow-artifacts/artifacts/<artifact_path>` to upload an artifact
|
|
to `artifact_path` (a relative path from the root artifact directory).
|
|
"""
|
|
artifact_path = validate_path_is_safe(artifact_path)
|
|
artifact_path = _get_workspace_scoped_repo_path_if_enabled(artifact_path)
|
|
head, tail = posixpath.split(artifact_path)
|
|
artifact_repo = _get_artifact_repo_mlflow_artifacts()
|
|
|
|
if isinstance(artifact_repo, StreamUploadMixin):
|
|
artifact_repo.log_artifact_from_stream(request.stream, tail, artifact_path=head or None)
|
|
else:
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp_path = os.path.join(tmp_dir, tail)
|
|
with open(tmp_path, "wb") as f:
|
|
while chunk := request.stream.read(ARTIFACT_STREAM_CHUNK_SIZE):
|
|
f.write(chunk)
|
|
artifact_repo.log_artifact(tmp_path, artifact_path=head or None)
|
|
|
|
return _wrap_response(UploadArtifact.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_unless_serve_artifacts
|
|
def _list_artifacts_mlflow_artifacts():
|
|
"""
|
|
A request handler for `GET /mlflow-artifacts/artifacts?path=<value>` to list artifacts in `path`
|
|
(a relative path from the root artifact directory).
|
|
"""
|
|
request_message = _get_request_message(ListArtifactsMlflowArtifacts())
|
|
path = validate_path_is_safe(request_message.path) if request_message.HasField("path") else None
|
|
path = _get_workspace_scoped_repo_path_if_enabled(path)
|
|
artifact_repo = _get_artifact_repo_mlflow_artifacts()
|
|
files = []
|
|
for file_info in artifact_repo.list_artifacts(path):
|
|
basename = posixpath.basename(file_info.path)
|
|
new_file_info = FileInfo(basename, file_info.is_dir, file_info.file_size)
|
|
files.append(new_file_info.to_proto())
|
|
response_message = ListArtifacts.Response()
|
|
response_message.files.extend(files)
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_unless_serve_artifacts
|
|
def _delete_artifact_mlflow_artifacts(artifact_path):
|
|
"""
|
|
A request handler for `DELETE /mlflow-artifacts/artifacts?path=<value>` to delete artifacts in
|
|
`path` (a relative path from the root artifact directory).
|
|
"""
|
|
artifact_path = validate_path_is_safe(artifact_path)
|
|
artifact_path = _get_workspace_scoped_repo_path_if_enabled(artifact_path)
|
|
_get_request_message(DeleteArtifact())
|
|
artifact_repo = _get_artifact_repo_mlflow_artifacts()
|
|
artifact_repo.delete_artifacts(artifact_path)
|
|
response_message = DeleteArtifact.Response()
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
def _get_graphql_auth_middleware():
|
|
"""
|
|
Get GraphQL authorization middleware if basic-auth is enabled.
|
|
|
|
Returns:
|
|
A list of middleware instances if auth is enabled, empty list otherwise.
|
|
"""
|
|
try:
|
|
from mlflow.server.auth import get_graphql_authorization_middleware
|
|
|
|
return get_graphql_authorization_middleware()
|
|
except Exception:
|
|
# Auth not configured or other error
|
|
return []
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def _graphql():
|
|
from graphql import parse
|
|
|
|
from mlflow.server.graphql.graphql_no_batching import check_query_safety
|
|
from mlflow.server.graphql.graphql_schema_extensions import schema
|
|
|
|
# Extracting the query, variables, and operationName from the request
|
|
request_json = _get_request_json()
|
|
query = request_json.get("query")
|
|
variables = request_json.get("variables")
|
|
operation_name = request_json.get("operationName")
|
|
|
|
node = parse(query)
|
|
if check_result := check_query_safety(node):
|
|
result = check_result
|
|
else:
|
|
# Get auth middleware if basic-auth is enabled
|
|
middleware = _get_graphql_auth_middleware()
|
|
|
|
# Executing the GraphQL query using the Graphene schema
|
|
result = schema.execute(
|
|
query,
|
|
variables=variables,
|
|
operation_name=operation_name,
|
|
middleware=middleware,
|
|
)
|
|
|
|
# Convert execution result into json.
|
|
result_data = {
|
|
"data": result.data,
|
|
"errors": [error.message for error in result.errors] if result.errors else None,
|
|
}
|
|
|
|
# Return the response
|
|
return jsonify(result_data)
|
|
|
|
|
|
def _validate_support_multipart_upload(artifact_repo):
|
|
if not isinstance(artifact_repo, MultipartUploadMixin):
|
|
raise _UnsupportedMultipartUploadException()
|
|
|
|
|
|
def _validate_support_multipart_download(artifact_repo):
|
|
if not isinstance(artifact_repo, MultipartDownloadMixin):
|
|
raise _UnsupportedMultipartDownloadException()
|
|
|
|
|
|
def _validate_support_presigned_upload(artifact_repo):
|
|
if not isinstance(artifact_repo, PresignedUploadMixin):
|
|
raise _UnsupportedPresignedUploadException()
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _create_presigned_upload_url():
|
|
"""
|
|
Handler for POST /api/2.0/mlflow/artifacts/presigned-upload-url.
|
|
Generates a presigned URL for uploading an artifact directly to cloud storage.
|
|
|
|
Client reference: https://github.com/aws/sagemaker-mlflow
|
|
"""
|
|
request_message = _get_request_message(
|
|
CreatePresignedUploadUrl(),
|
|
schema={
|
|
"run_id": [_assert_required, _assert_string],
|
|
"path": [_assert_required, _assert_string],
|
|
"expiration": [_assert_intlike],
|
|
},
|
|
)
|
|
run_id = request_message.run_id
|
|
path = validate_path_is_safe(request_message.path)
|
|
expiration = request_message.expiration if request_message.HasField("expiration") else 900
|
|
|
|
run = _get_tracking_store().get_run(run_id)
|
|
artifact_uri = run.info.artifact_uri
|
|
artifact_uri_scheme = urllib.parse.urlparse(artifact_uri).scheme
|
|
if artifact_uri_scheme in ("http", "https", "mlflow-artifacts"):
|
|
raise MlflowException(
|
|
"Presigned upload is not supported for runs with proxied artifact storage "
|
|
f"(artifact URI scheme: {artifact_uri_scheme}). "
|
|
"This endpoint requires a run with a direct cloud storage artifact URI.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
artifact_repo = _get_artifact_repo(run)
|
|
_validate_support_presigned_upload(artifact_repo)
|
|
|
|
response = artifact_repo.create_presigned_upload_url(path, expiration=expiration)
|
|
response_message = response.to_proto()
|
|
resp = Response(mimetype="application/json")
|
|
resp.set_data(message_to_json(response_message))
|
|
return resp
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_unless_serve_artifacts
|
|
def _create_multipart_upload_artifact(artifact_path):
|
|
"""
|
|
A request handler for `POST /mlflow-artifacts/mpu/create` to create a multipart upload
|
|
to `artifact_path` (a relative path from the root artifact directory).
|
|
"""
|
|
artifact_path = validate_path_is_safe(artifact_path)
|
|
artifact_path = _get_workspace_scoped_repo_path_if_enabled(artifact_path)
|
|
|
|
request_message = _get_request_message(
|
|
CreateMultipartUpload(),
|
|
schema={
|
|
"path": [_assert_required, _assert_string],
|
|
"num_parts": [_assert_intlike],
|
|
},
|
|
)
|
|
path = request_message.path
|
|
num_parts = request_message.num_parts
|
|
|
|
artifact_repo = _get_artifact_repo_mlflow_artifacts()
|
|
_validate_support_multipart_upload(artifact_repo)
|
|
|
|
create_response = artifact_repo.create_multipart_upload(
|
|
path,
|
|
num_parts,
|
|
artifact_path,
|
|
)
|
|
response_message = create_response.to_proto()
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_unless_serve_artifacts
|
|
def _complete_multipart_upload_artifact(artifact_path):
|
|
"""
|
|
A request handler for `POST /mlflow-artifacts/mpu/complete` to complete a multipart upload
|
|
to `artifact_path` (a relative path from the root artifact directory).
|
|
"""
|
|
artifact_path = validate_path_is_safe(artifact_path)
|
|
artifact_path = _get_workspace_scoped_repo_path_if_enabled(artifact_path)
|
|
|
|
request_message = _get_request_message(
|
|
CompleteMultipartUpload(),
|
|
schema={
|
|
"path": [_assert_required, _assert_string],
|
|
"upload_id": [_assert_string],
|
|
"parts": [_assert_required],
|
|
},
|
|
)
|
|
path = request_message.path
|
|
upload_id = request_message.upload_id
|
|
parts = [MultipartUploadPart.from_proto(part) for part in request_message.parts]
|
|
|
|
artifact_repo = _get_artifact_repo_mlflow_artifacts()
|
|
_validate_support_multipart_upload(artifact_repo)
|
|
|
|
artifact_repo.complete_multipart_upload(
|
|
path,
|
|
upload_id,
|
|
parts,
|
|
artifact_path,
|
|
)
|
|
return _wrap_response(CompleteMultipartUpload.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_unless_serve_artifacts
|
|
def _abort_multipart_upload_artifact(artifact_path):
|
|
"""
|
|
A request handler for `POST /mlflow-artifacts/mpu/abort` to abort a multipart upload
|
|
to `artifact_path` (a relative path from the root artifact directory).
|
|
"""
|
|
artifact_path = validate_path_is_safe(artifact_path)
|
|
artifact_path = _get_workspace_scoped_repo_path_if_enabled(artifact_path)
|
|
|
|
request_message = _get_request_message(
|
|
AbortMultipartUpload(),
|
|
schema={
|
|
"path": [_assert_required, _assert_string],
|
|
"upload_id": [_assert_string],
|
|
},
|
|
)
|
|
path = request_message.path
|
|
upload_id = request_message.upload_id
|
|
|
|
artifact_repo = _get_artifact_repo_mlflow_artifacts()
|
|
_validate_support_multipart_upload(artifact_repo)
|
|
|
|
artifact_repo.abort_multipart_upload(
|
|
path,
|
|
upload_id,
|
|
artifact_path,
|
|
)
|
|
return _wrap_response(AbortMultipartUpload.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_unless_serve_artifacts
|
|
def _get_presigned_download_url(artifact_path):
|
|
"""
|
|
A request handler for `GET /mlflow-artifacts/presigned/<artifact_path>` to get
|
|
a presigned URL for downloading an artifact directly from cloud storage.
|
|
"""
|
|
artifact_path = validate_path_is_safe(artifact_path)
|
|
|
|
artifact_repo = _get_artifact_repo_mlflow_artifacts()
|
|
_validate_support_multipart_download(artifact_repo)
|
|
|
|
expiration = MLFLOW_PRESIGNED_DOWNLOAD_URL_TTL_SECONDS.get()
|
|
presigned_response = artifact_repo.get_download_presigned_url(
|
|
artifact_path, expiration=expiration
|
|
)
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(json.dumps(presigned_response.to_dict()))
|
|
return response
|
|
|
|
|
|
# MLflow Tracing APIs
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _start_trace_v3():
|
|
"""
|
|
A request handler for `POST /mlflow/traces` to create a new TraceInfo record in tracking store.
|
|
"""
|
|
request_message = _get_request_message(
|
|
StartTraceV3(),
|
|
schema={"trace": [_assert_required]},
|
|
)
|
|
trace_info = TraceInfo.from_proto(request_message.trace.trace_info)
|
|
trace_info = _get_tracking_store().start_trace(trace_info)
|
|
response_message = StartTraceV3.Response(trace=ProtoTrace(trace_info=trace_info.to_proto()))
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_trace_info_v3(trace_id):
|
|
"""
|
|
A request handler for `GET /mlflow/traces/{trace_id}` to retrieve
|
|
an existing TraceInfo record from tracking store.
|
|
"""
|
|
trace_info = _get_tracking_store().get_trace_info(trace_id)
|
|
response_message = GetTraceInfoV3.Response(trace=ProtoTrace(trace_info=trace_info.to_proto()))
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _batch_get_traces() -> Response:
|
|
"""
|
|
A request handler for `GET /mlflow/traces/batchGet` to retrieve
|
|
a batch of complete traces with spans for given trace ids.
|
|
"""
|
|
request_message = _get_request_message(
|
|
BatchGetTraces(),
|
|
schema={"trace_ids": [_assert_array, _assert_required, _assert_item_type_string]},
|
|
)
|
|
traces = _get_tracking_store().batch_get_traces(request_message.trace_ids, None)
|
|
response_message = BatchGetTraces.Response()
|
|
response_message.traces.extend([t.to_proto() for t in traces])
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _batch_get_trace_infos() -> Response:
|
|
request_message = _get_request_message(
|
|
BatchGetTraceInfos(),
|
|
schema={"trace_ids": [_assert_array, _assert_required, _assert_item_type_string]},
|
|
)
|
|
trace_infos = _get_tracking_store().batch_get_trace_infos(request_message.trace_ids)
|
|
response_message = BatchGetTraceInfos.Response()
|
|
response_message.trace_infos.extend([ti.to_proto() for ti in trace_infos])
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_trace() -> Response:
|
|
"""
|
|
A request handler for `GET /mlflow/traces/get` to get a trace with spans for given trace id.
|
|
"""
|
|
request_message = _get_request_message(
|
|
GetTrace(),
|
|
schema={
|
|
"trace_id": [_assert_string, _assert_required],
|
|
"allow_partial": [_assert_bool],
|
|
},
|
|
)
|
|
trace_id = request_message.trace_id
|
|
allow_partial = request_message.allow_partial
|
|
trace = _get_tracking_store().get_trace(trace_id, allow_partial=allow_partial)
|
|
response_message = GetTrace.Response(trace=trace.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _search_traces_v3():
|
|
"""
|
|
A request handler for `GET /mlflow/traces` to search for TraceInfo records in tracking store.
|
|
"""
|
|
request_message = _get_request_message(
|
|
SearchTracesV3(),
|
|
schema={
|
|
"locations": [_assert_array, _assert_required],
|
|
"filter": [_assert_string],
|
|
"max_results": [
|
|
_assert_intlike,
|
|
lambda x: _assert_less_than_or_equal(int(x), 500),
|
|
],
|
|
"order_by": [_assert_array, _assert_item_type_string],
|
|
"page_token": [_assert_string],
|
|
},
|
|
)
|
|
experiment_ids = [
|
|
location.mlflow_experiment.experiment_id
|
|
for location in request_message.locations
|
|
if location.HasField("mlflow_experiment")
|
|
]
|
|
|
|
traces, token = _get_tracking_store().search_traces(
|
|
locations=experiment_ids,
|
|
filter_string=request_message.filter,
|
|
max_results=request_message.max_results,
|
|
order_by=request_message.order_by,
|
|
page_token=request_message.page_token or None,
|
|
)
|
|
response_message = SearchTracesV3.Response()
|
|
response_message.traces.extend([e.to_proto() for e in traces])
|
|
if token:
|
|
response_message.next_page_token = token
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_traces():
|
|
"""
|
|
A request handler for `POST /mlflow/traces/delete-traces` to delete TraceInfo records
|
|
from tracking store.
|
|
"""
|
|
request_message = _get_request_message(
|
|
DeleteTraces(),
|
|
schema={
|
|
"experiment_id": [_assert_string, _assert_required],
|
|
"max_timestamp_millis": [_assert_intlike],
|
|
"max_traces": [_assert_intlike],
|
|
"request_ids": [_assert_array, _assert_item_type_string],
|
|
},
|
|
)
|
|
|
|
# NB: Interestingly, the field accessor for the message object returns the default
|
|
# value for optional field if it's not set. For example, `request_message.max_traces`
|
|
# returns 0 if max_traces is not specified in the request. This is not desirable,
|
|
# because null and 0 means completely opposite i.e. the former is 'delete nothing'
|
|
# while the latter is 'delete all'. To handle this, we need to explicitly check
|
|
# if the field is set or not using `HasField` method and return None if not.
|
|
def _get_nullable_field(field):
|
|
if request_message.HasField(field):
|
|
return getattr(request_message, field)
|
|
return None
|
|
|
|
traces_deleted = _get_tracking_store().delete_traces(
|
|
experiment_id=request_message.experiment_id,
|
|
max_timestamp_millis=_get_nullable_field("max_timestamp_millis"),
|
|
max_traces=_get_nullable_field("max_traces"),
|
|
trace_ids=request_message.request_ids,
|
|
)
|
|
return _wrap_response(DeleteTraces.Response(traces_deleted=traces_deleted))
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _calculate_trace_filter_correlation():
|
|
"""
|
|
A request handler for `POST /mlflow/traces/calculate-filter-correlation` to calculate
|
|
NPMI correlation between two trace filter conditions.
|
|
"""
|
|
request_message = _get_request_message(
|
|
CalculateTraceFilterCorrelation(),
|
|
schema={
|
|
"experiment_ids": [_assert_array, _assert_required, _assert_item_type_string],
|
|
"filter_string1": [_assert_string, _assert_required],
|
|
"filter_string2": [_assert_string, _assert_required],
|
|
"base_filter": [_assert_string],
|
|
},
|
|
)
|
|
|
|
result = _get_tracking_store().calculate_trace_filter_correlation(
|
|
experiment_ids=request_message.experiment_ids,
|
|
filter_string1=request_message.filter_string1,
|
|
filter_string2=request_message.filter_string2,
|
|
base_filter=request_message.base_filter
|
|
if request_message.HasField("base_filter")
|
|
else None,
|
|
)
|
|
|
|
return _wrap_response(result.to_proto())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _set_trace_tag(request_id):
|
|
"""
|
|
A request handler for `PATCH /mlflow/traces/{request_id}/tags` to set tags on a TraceInfo record
|
|
"""
|
|
request_message = _get_request_message(
|
|
SetTraceTag(),
|
|
schema={
|
|
"key": [_assert_string, _assert_required],
|
|
"value": [_assert_string],
|
|
},
|
|
)
|
|
_validate_trace_tag_handler_mutation(request_message.key, "set")
|
|
_get_tracking_store().set_trace_tag(request_id, request_message.key, request_message.value)
|
|
return _wrap_response(SetTraceTag.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _set_trace_tag_v3(trace_id):
|
|
"""
|
|
A request handler for `PATCH /mlflow/traces/{trace_id}/tags` to set tags on a TraceInfo record.
|
|
Identical to `_set_trace_tag`, but with request_id renamed to with trace_id.
|
|
"""
|
|
request_message = _get_request_message(
|
|
SetTraceTagV3(),
|
|
schema={
|
|
"key": [_assert_string, _assert_required],
|
|
"value": [_assert_string],
|
|
},
|
|
)
|
|
_validate_trace_tag_handler_mutation(request_message.key, "set")
|
|
_get_tracking_store().set_trace_tag(trace_id, request_message.key, request_message.value)
|
|
return _wrap_response(SetTraceTagV3.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_trace_tag(request_id):
|
|
"""
|
|
A request handler for `DELETE /mlflow/traces/{request_id}/tags` to delete tags from a TraceInfo
|
|
record.
|
|
"""
|
|
request_message = _get_request_message(
|
|
DeleteTraceTag(),
|
|
schema={
|
|
"key": [_assert_string, _assert_required],
|
|
},
|
|
)
|
|
_validate_trace_tag_handler_mutation(request_message.key, "deleted")
|
|
_get_tracking_store().delete_trace_tag(request_id, request_message.key)
|
|
return _wrap_response(DeleteTraceTag.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_trace_tag_v3(trace_id):
|
|
"""
|
|
A request handler for `DELETE /mlflow/traces/{trace_id}/tags` to delete tags
|
|
from a TraceInfo record.
|
|
Identical to `_delete_trace_tag`, but with request_id renamed to with trace_id.
|
|
"""
|
|
request_message = _get_request_message(
|
|
DeleteTraceTagV3(),
|
|
schema={
|
|
"key": [_assert_string, _assert_required],
|
|
},
|
|
)
|
|
_validate_trace_tag_handler_mutation(request_message.key, "deleted")
|
|
_get_tracking_store().delete_trace_tag(trace_id, request_message.key)
|
|
return _wrap_response(DeleteTraceTagV3.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _link_traces_to_run():
|
|
"""
|
|
A request handler for `POST /mlflow/traces/link-to-run` to link traces to a run.
|
|
"""
|
|
request_message = _get_request_message(
|
|
LinkTracesToRun(),
|
|
schema={
|
|
"trace_ids": [_assert_array, _assert_required, _assert_item_type_string],
|
|
"run_id": [_assert_string, _assert_required],
|
|
},
|
|
)
|
|
_get_tracking_store().link_traces_to_run(
|
|
trace_ids=request_message.trace_ids,
|
|
run_id=request_message.run_id,
|
|
)
|
|
return _wrap_response(LinkTracesToRun.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _link_prompts_to_trace():
|
|
"""
|
|
A request handler for `POST /mlflow/traces/link-prompts` to link prompt versions to a trace.
|
|
"""
|
|
from mlflow.entities.model_registry import PromptVersion
|
|
|
|
request_message = _get_request_message(
|
|
LinkPromptsToTrace(),
|
|
schema={
|
|
"trace_id": [_assert_string, _assert_required],
|
|
"prompt_versions": [_assert_array, _assert_required],
|
|
},
|
|
)
|
|
|
|
# Convert PromptVersionRef proto messages to PromptVersion objects
|
|
# It doesn't load prompt versions since name and version are sufficient for linking
|
|
prompt_versions = [
|
|
PromptVersion(name=pv.name, version=int(pv.version), template="")
|
|
for pv in request_message.prompt_versions
|
|
]
|
|
|
|
_get_tracking_store().link_prompts_to_trace(
|
|
trace_id=request_message.trace_id,
|
|
prompt_versions=prompt_versions,
|
|
)
|
|
return _wrap_response(LinkPromptsToTrace.Response())
|
|
|
|
|
|
def _fetch_trace_data_from_store(
|
|
store: AbstractTrackingStore, request_id: str
|
|
) -> dict[str, Any] | None:
|
|
try:
|
|
# allow partial so the frontend can render in-progress traces
|
|
trace = store.get_trace(request_id, allow_partial=True)
|
|
return trace.data.to_dict()
|
|
except MlflowTraceDataException:
|
|
raise
|
|
except MlflowTracingException:
|
|
return None
|
|
except MlflowNotImplementedException:
|
|
# fallback to batch_get_traces if get_trace is not implemented
|
|
pass
|
|
|
|
try:
|
|
traces = store.batch_get_traces([request_id], None)
|
|
match traces:
|
|
case [trace]:
|
|
return trace.data.to_dict()
|
|
case _:
|
|
raise MlflowException(
|
|
f"Trace with id={request_id} not found.",
|
|
error_code=RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
except MlflowTraceDataException:
|
|
raise
|
|
# For stores that don't support batch get traces, or if trace data is not in the store,
|
|
# return None to signal fallback to artifact repository
|
|
except (MlflowTracingException, MlflowNotImplementedException):
|
|
return None
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def get_trace_artifact_handler() -> Response:
|
|
request_id = request.args.get("request_id")
|
|
path = request.args.get("path")
|
|
|
|
if not request_id:
|
|
raise MlflowException(
|
|
'Request must include the "request_id" query parameter.',
|
|
error_code=BAD_REQUEST,
|
|
)
|
|
|
|
store = _get_tracking_store()
|
|
|
|
if path:
|
|
path = validate_path_is_safe(path)
|
|
trace_info = store.get_trace_info(request_id)
|
|
if trace_info is None:
|
|
raise MlflowException(
|
|
f"Trace with ID '{request_id}' not found.",
|
|
error_code=RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
repo = _get_trace_artifact_repo(trace_info)
|
|
try:
|
|
content_bytes = repo.download_trace_attachment(path)
|
|
except MlflowException:
|
|
raise
|
|
except Exception:
|
|
_logger.warning(
|
|
"Failed to download attachment '%s' for trace '%s'",
|
|
path,
|
|
request_id,
|
|
exc_info=True,
|
|
)
|
|
raise MlflowException(
|
|
f"Failed to download attachment '{path}' for trace '{request_id}'.",
|
|
error_code=INTERNAL_ERROR,
|
|
)
|
|
buf = io.BytesIO(content_bytes)
|
|
file_sender_response = send_file(
|
|
buf,
|
|
mimetype="application/octet-stream",
|
|
as_attachment=True,
|
|
download_name=path,
|
|
)
|
|
return _response_with_file_attachment_headers(path, file_sender_response)
|
|
|
|
trace_data = _fetch_trace_data_from_store(store, request_id)
|
|
if trace_data is None:
|
|
trace_info = store.get_trace_info(request_id)
|
|
if trace_info.tags.get(TraceTagKey.SPANS_LOCATION) == SpansLocation.ARCHIVE_REPO.value:
|
|
trace_data = (
|
|
_get_trace_archive_repo(trace_info).download_archived_trace_data().to_dict()
|
|
)
|
|
else:
|
|
trace_data = _get_trace_artifact_repo(trace_info).download_trace_data()
|
|
|
|
# Write data to a BytesIO buffer instead of needing to save a temp file
|
|
buf = io.BytesIO()
|
|
buf.write(json.dumps(trace_data).encode())
|
|
buf.seek(0)
|
|
|
|
file_sender_response = send_file(
|
|
buf,
|
|
mimetype="application/octet-stream",
|
|
as_attachment=True,
|
|
download_name=TRACE_DATA_FILE_NAME,
|
|
)
|
|
return _response_with_file_attachment_headers(TRACE_DATA_FILE_NAME, file_sender_response)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _query_trace_metrics() -> Response:
|
|
request_message = _get_request_message(
|
|
QueryTraceMetrics(),
|
|
schema={
|
|
"experiment_ids": [_assert_array, _assert_required, _assert_item_type_string],
|
|
"view_type": [_assert_required],
|
|
"metric_name": [_assert_string, _assert_required],
|
|
"aggregations": [_assert_array, _assert_required],
|
|
"dimensions": [_assert_array, _assert_item_type_string],
|
|
"filters": [_assert_array, _assert_item_type_string],
|
|
"time_interval_seconds": [_assert_intlike],
|
|
"start_time_ms": [_assert_intlike],
|
|
"end_time_ms": [_assert_intlike],
|
|
"max_results": [_assert_intlike],
|
|
"page_token": [_assert_string],
|
|
},
|
|
)
|
|
max_results = (
|
|
request_message.max_results
|
|
if request_message.HasField("max_results")
|
|
else MAX_RESULTS_QUERY_TRACE_METRICS
|
|
)
|
|
time_interval_seconds = (
|
|
request_message.time_interval_seconds
|
|
if request_message.HasField("time_interval_seconds")
|
|
else None
|
|
)
|
|
start_time_ms = (
|
|
request_message.start_time_ms if request_message.HasField("start_time_ms") else None
|
|
)
|
|
end_time_ms = request_message.end_time_ms if request_message.HasField("end_time_ms") else None
|
|
|
|
result = _get_tracking_store().query_trace_metrics(
|
|
experiment_ids=request_message.experiment_ids,
|
|
view_type=MetricViewType.from_proto(request_message.view_type),
|
|
metric_name=request_message.metric_name,
|
|
aggregations=[MetricAggregation.from_proto(agg) for agg in request_message.aggregations],
|
|
dimensions=request_message.dimensions or None,
|
|
filters=request_message.filters or None,
|
|
time_interval_seconds=time_interval_seconds,
|
|
start_time_ms=start_time_ms,
|
|
end_time_ms=end_time_ms,
|
|
max_results=max_results,
|
|
page_token=request_message.page_token or None,
|
|
)
|
|
response_message = QueryTraceMetrics.Response()
|
|
response_message.data_points.extend([dp.to_proto() for dp in result])
|
|
if result.token:
|
|
response_message.next_page_token = result.token
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
# Assessments API handlers
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _create_assessment(trace_id):
|
|
"""
|
|
A request handler for `POST /mlflow/traces/{assessment.trace_id}/assessments`
|
|
to create a new assessment.
|
|
"""
|
|
request_message = _get_request_message(
|
|
CreateAssessment(),
|
|
schema={
|
|
"assessment": [_assert_required],
|
|
},
|
|
)
|
|
|
|
assessment = Assessment.from_proto(request_message.assessment)
|
|
assessment.trace_id = trace_id
|
|
created_assessment = _get_tracking_store().create_assessment(assessment)
|
|
|
|
response_message = CreateAssessment.Response(assessment=created_assessment.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_assessment(trace_id, assessment_id):
|
|
"""
|
|
A request handler for `GET /mlflow/traces/{trace_id}/assessments/{assessment_id}`
|
|
to get an assessment.
|
|
"""
|
|
assessment = _get_tracking_store().get_assessment(trace_id, assessment_id)
|
|
|
|
response_message = GetAssessmentRequest.Response(assessment=assessment.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _update_assessment(trace_id, assessment_id):
|
|
"""
|
|
A request handler for `PATCH /mlflow/traces/{trace_id}/assessments/{assessment_id}`
|
|
to update an assessment.
|
|
"""
|
|
request_message = _get_request_message(
|
|
UpdateAssessment(),
|
|
schema={
|
|
"assessment": [_assert_required],
|
|
"update_mask": [_assert_required],
|
|
},
|
|
)
|
|
|
|
assessment_proto = request_message.assessment
|
|
update_mask = request_message.update_mask
|
|
|
|
kwargs = {}
|
|
|
|
for path in update_mask.paths:
|
|
if path == "assessment_name":
|
|
kwargs["name"] = assessment_proto.assessment_name
|
|
elif path == "expectation":
|
|
kwargs["expectation"] = Expectation.from_proto(assessment_proto)
|
|
elif path == "feedback":
|
|
kwargs["feedback"] = Feedback.from_proto(assessment_proto)
|
|
elif path == "rationale":
|
|
kwargs["rationale"] = assessment_proto.rationale
|
|
elif path == "metadata":
|
|
kwargs["metadata"] = dict(assessment_proto.metadata)
|
|
elif path == "valid":
|
|
kwargs["valid"] = assessment_proto.valid
|
|
|
|
updated_assessment = _get_tracking_store().update_assessment(
|
|
trace_id=trace_id, assessment_id=assessment_id, **kwargs
|
|
)
|
|
|
|
response_message = UpdateAssessment.Response(assessment=updated_assessment.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_assessment(trace_id, assessment_id):
|
|
"""
|
|
A request handler for `DELETE /mlflow/traces/{trace_id}/assessments/{assessment_id}`
|
|
to delete an assessment.
|
|
"""
|
|
_get_tracking_store().delete_assessment(trace_id, assessment_id)
|
|
|
|
response_message = DeleteAssessment.Response()
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _create_issue():
|
|
"""
|
|
A request handler for `POST /mlflow/issues` to create a new issue.
|
|
"""
|
|
request_message = _get_request_message(
|
|
CreateIssue(),
|
|
schema={
|
|
"name": [_assert_required, _assert_string],
|
|
"description": [_assert_required, _assert_string],
|
|
"experiment_id": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
|
|
# Build kwargs for create_issue
|
|
create_kwargs = {
|
|
"experiment_id": request_message.experiment_id,
|
|
"name": request_message.name,
|
|
"description": request_message.description,
|
|
"source_run_id": request_message.source_run_id or None,
|
|
"root_causes": list(request_message.root_causes) or None,
|
|
"categories": list(request_message.categories) or None,
|
|
"created_by": request_message.created_by or None,
|
|
}
|
|
|
|
if request_message.HasField("status"):
|
|
create_kwargs["status"] = IssueStatus(request_message.status)
|
|
if request_message.HasField("severity"):
|
|
create_kwargs["severity"] = IssueSeverity(request_message.severity)
|
|
|
|
created_issue = _get_tracking_store().create_issue(**create_kwargs)
|
|
|
|
response_message = CreateIssue.Response(issue=created_issue.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _update_issue(issue_id):
|
|
"""
|
|
A request handler for `PATCH /mlflow/issues/{issue_id}` to update an issue.
|
|
"""
|
|
request_message = _get_request_message(
|
|
UpdateIssue(),
|
|
schema={
|
|
"issue_id": [_assert_required],
|
|
},
|
|
)
|
|
|
|
status = IssueStatus(request_message.status) if request_message.HasField("status") else None
|
|
severity = (
|
|
IssueSeverity(request_message.severity) if request_message.HasField("severity") else None
|
|
)
|
|
|
|
updated_issue = _get_tracking_store().update_issue(
|
|
issue_id=issue_id,
|
|
status=status,
|
|
name=request_message.name or None,
|
|
description=request_message.description or None,
|
|
severity=severity,
|
|
)
|
|
|
|
response_message = UpdateIssue.Response(issue=updated_issue.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_issue(issue_id):
|
|
"""
|
|
A request handler for `GET /mlflow/issues/{issue_id}` to get an issue.
|
|
"""
|
|
issue = _get_tracking_store().get_issue(issue_id)
|
|
|
|
response_message = GetIssue.Response(issue=issue.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _search_issues():
|
|
"""
|
|
A request handler for `POST /mlflow/issues/search` to search for issues.
|
|
"""
|
|
request_message = _get_request_message(SearchIssues())
|
|
|
|
# Build kwargs for search_issues
|
|
search_kwargs = {
|
|
"experiment_id": request_message.experiment_id or None,
|
|
"filter_string": request_message.filter_string or None,
|
|
"page_token": request_message.page_token or None,
|
|
}
|
|
|
|
if request_message.HasField("max_results"):
|
|
search_kwargs["max_results"] = request_message.max_results
|
|
|
|
if request_message.HasField("include_trace_count"):
|
|
search_kwargs["include_trace_count"] = request_message.include_trace_count
|
|
|
|
issues = _get_tracking_store().search_issues(**search_kwargs)
|
|
|
|
issue_protos = [issue.to_proto() for issue in issues]
|
|
response_message = SearchIssues.Response(
|
|
issues=issue_protos, next_page_token=issues.token or ""
|
|
)
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
# =============================================================================
|
|
# Label Schema Handlers (tracking-store CRUD; see mlflow/genai/label_schemas/)
|
|
# =============================================================================
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _create_label_schema():
|
|
request_message = _get_request_message(
|
|
CreateLabelSchema(),
|
|
schema={
|
|
"experiment_id": [_assert_required, _assert_string],
|
|
"name": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
schema_type = LabelSchemaType.from_proto(request_message.type)
|
|
input_obj = _input_from_proto(request_message.input)
|
|
kwargs: dict[str, object] = {
|
|
"experiment_id": request_message.experiment_id,
|
|
"name": request_message.name,
|
|
"type": schema_type,
|
|
"input": input_obj,
|
|
}
|
|
if request_message.HasField("instruction"):
|
|
kwargs["instruction"] = request_message.instruction
|
|
if request_message.HasField("enable_comment"):
|
|
kwargs["enable_comment"] = request_message.enable_comment
|
|
created = _get_tracking_store().create_label_schema(**kwargs)
|
|
return _wrap_response(CreateLabelSchema.Response(label_schema=created.to_proto()))
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_label_schema():
|
|
request_message = _get_request_message(
|
|
GetLabelSchema(),
|
|
schema={"schema_id": [_assert_required, _assert_string]},
|
|
)
|
|
schema = _get_tracking_store().get_label_schema(request_message.schema_id)
|
|
return _wrap_response(GetLabelSchema.Response(label_schema=schema.to_proto()))
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_label_schema_by_name():
|
|
request_message = _get_request_message(
|
|
GetLabelSchemaByName(),
|
|
schema={
|
|
"experiment_id": [_assert_required, _assert_string],
|
|
"name": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
schema = _get_tracking_store().get_label_schema_by_name(
|
|
request_message.experiment_id, request_message.name
|
|
)
|
|
return _wrap_response(GetLabelSchemaByName.Response(label_schema=schema.to_proto()))
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _list_label_schemas():
|
|
request_message = _get_request_message(
|
|
ListLabelSchemas(),
|
|
schema={
|
|
"experiment_id": [_assert_required, _assert_string],
|
|
"max_results": [
|
|
_assert_intlike,
|
|
lambda x: _assert_intlike_within_range(
|
|
int(x),
|
|
1,
|
|
SEARCH_MAX_RESULTS_THRESHOLD,
|
|
message=(f"max_results must be between 1 and {SEARCH_MAX_RESULTS_THRESHOLD}."),
|
|
),
|
|
],
|
|
},
|
|
)
|
|
max_results = request_message.max_results if request_message.HasField("max_results") else 100
|
|
page_token = request_message.page_token if request_message.HasField("page_token") else None
|
|
schemas = _get_tracking_store().list_label_schemas(
|
|
request_message.experiment_id, max_results=max_results, page_token=page_token
|
|
)
|
|
response = ListLabelSchemas.Response(
|
|
label_schemas=[s.to_proto() for s in schemas],
|
|
next_page_token=schemas.token or "",
|
|
)
|
|
return _wrap_response(response)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _update_label_schema():
|
|
request_message = _get_request_message(
|
|
UpdateLabelSchema(),
|
|
schema={"schema_id": [_assert_required, _assert_string]},
|
|
)
|
|
kwargs: dict[str, object] = {}
|
|
if request_message.HasField("name"):
|
|
kwargs["name"] = request_message.name
|
|
if request_message.HasField("instruction"):
|
|
kwargs["instruction"] = request_message.instruction
|
|
if request_message.HasField("enable_comment"):
|
|
kwargs["enable_comment"] = request_message.enable_comment
|
|
if request_message.HasField("input"):
|
|
kwargs["input"] = _input_from_proto(request_message.input)
|
|
updated = _get_tracking_store().update_label_schema(request_message.schema_id, **kwargs)
|
|
return _wrap_response(UpdateLabelSchema.Response(label_schema=updated.to_proto()))
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_label_schema():
|
|
request_message = _get_request_message(
|
|
DeleteLabelSchema(),
|
|
schema={"schema_id": [_assert_required, _assert_string]},
|
|
)
|
|
_get_tracking_store().delete_label_schema(request_message.schema_id)
|
|
return _wrap_response(DeleteLabelSchema.Response())
|
|
|
|
|
|
# =============================================================================
|
|
# Review Queue Handlers (tracking-store CRUD; see mlflow/genai/review_queues/)
|
|
# =============================================================================
|
|
|
|
|
|
def _review_queue_max_results_validator(x):
|
|
return _assert_intlike_within_range(
|
|
int(x),
|
|
1,
|
|
SEARCH_MAX_RESULTS_THRESHOLD,
|
|
message=f"max_results must be between 1 and {SEARCH_MAX_RESULTS_THRESHOLD}.",
|
|
)
|
|
|
|
|
|
def _get_request_username():
|
|
"""The authenticated request user, stamped on ``flask.g`` by the auth
|
|
plugin's before-request hook. ``None`` when no auth plugin is active (a
|
|
no-auth server), where queue ownership is meaningless.
|
|
"""
|
|
return getattr(g, "mlflow_authenticated_user", None)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _create_review_queue():
|
|
request_message = _get_request_message(
|
|
CreateReviewQueue(),
|
|
schema={
|
|
"experiment_id": [_assert_required, _assert_string],
|
|
"name": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
# `from_proto` rejects the proto2 zero-value (UNSPECIFIED); don't replace
|
|
# it with an `_assert_required` schema entry — that only checks HasField,
|
|
# not enum-value validity, and would change the rejection's error shape.
|
|
kwargs: dict[str, object] = {
|
|
"experiment_id": request_message.experiment_id,
|
|
"name": request_message.name,
|
|
"queue_type": ReviewQueueType.from_proto(request_message.queue_type),
|
|
"users": list(request_message.users),
|
|
"schema_ids": list(request_message.schema_ids),
|
|
}
|
|
# `created_by` is the owner: stamp it from the authenticated user, never the
|
|
# client. Stays unset on a no-auth server (owner is meaningless there).
|
|
username = _get_request_username()
|
|
if username is not None:
|
|
kwargs["created_by"] = username
|
|
created = _get_tracking_store().create_review_queue(**kwargs)
|
|
return _wrap_response(CreateReviewQueue.Response(review_queue=created.to_proto()))
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_or_create_user_queue():
|
|
request_message = _get_request_message(
|
|
GetOrCreateUserQueue(),
|
|
schema={
|
|
"experiment_id": [_assert_required, _assert_string],
|
|
"user": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
# A user queue is owned by its user (set in the store), not by any client value.
|
|
queue = _get_tracking_store().get_or_create_user_queue(
|
|
request_message.experiment_id, user=request_message.user
|
|
)
|
|
return _wrap_response(GetOrCreateUserQueue.Response(review_queue=queue.to_proto()))
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_review_queue():
|
|
request_message = _get_request_message(
|
|
GetReviewQueue(),
|
|
schema={"queue_id": [_assert_required, _assert_string]},
|
|
)
|
|
queue = _get_tracking_store().get_review_queue(request_message.queue_id)
|
|
return _wrap_response(GetReviewQueue.Response(review_queue=queue.to_proto()))
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_review_queue_by_name():
|
|
request_message = _get_request_message(
|
|
GetReviewQueueByName(),
|
|
schema={
|
|
"experiment_id": [_assert_required, _assert_string],
|
|
"name": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
queue = _get_tracking_store().get_review_queue_by_name(
|
|
request_message.experiment_id, name=request_message.name
|
|
)
|
|
return _wrap_response(GetReviewQueueByName.Response(review_queue=queue.to_proto()))
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _list_review_queues():
|
|
request_message = _get_request_message(
|
|
ListReviewQueues(),
|
|
schema={
|
|
"experiment_id": [_assert_required, _assert_string],
|
|
"max_results": [_assert_intlike, _review_queue_max_results_validator],
|
|
},
|
|
)
|
|
max_results = request_message.max_results if request_message.HasField("max_results") else None
|
|
page_token = request_message.page_token if request_message.HasField("page_token") else None
|
|
user = request_message.user if request_message.HasField("user") else None
|
|
item_id = request_message.item_id if request_message.HasField("item_id") else None
|
|
queues = _get_tracking_store().list_review_queues(
|
|
request_message.experiment_id,
|
|
user=user,
|
|
item_id=item_id,
|
|
max_results=max_results,
|
|
page_token=page_token,
|
|
)
|
|
response = ListReviewQueues.Response(
|
|
review_queues=[q.to_proto() for q in queues],
|
|
next_page_token=queues.token or "",
|
|
)
|
|
return _wrap_response(response)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _update_review_queue():
|
|
request_message = _get_request_message(
|
|
UpdateReviewQueue(),
|
|
schema={"queue_id": [_assert_required, _assert_string]},
|
|
)
|
|
users = list(request_message.users) if request_message.update_users else None
|
|
schema_ids = list(request_message.schema_ids) if request_message.update_schema_ids else None
|
|
# Singular fields use proto2 presence (no update_* flag).
|
|
name = request_message.name if request_message.HasField("name") else None
|
|
new_owner = request_message.new_owner if request_message.HasField("new_owner") else None
|
|
updated = _get_tracking_store().update_review_queue(
|
|
request_message.queue_id,
|
|
users=users,
|
|
schema_ids=schema_ids,
|
|
name=name,
|
|
new_owner=new_owner,
|
|
)
|
|
return _wrap_response(UpdateReviewQueue.Response(review_queue=updated.to_proto()))
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_review_queue():
|
|
request_message = _get_request_message(
|
|
DeleteReviewQueue(),
|
|
schema={"queue_id": [_assert_required, _assert_string]},
|
|
)
|
|
_get_tracking_store().delete_review_queue(request_message.queue_id)
|
|
return _wrap_response(DeleteReviewQueue.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _add_items_to_review_queue():
|
|
request_message = _get_request_message(
|
|
AddItemsToReviewQueue(),
|
|
schema={"queue_id": [_assert_required, _assert_string]},
|
|
)
|
|
store = _get_tracking_store()
|
|
# Normalize (strip + de-dup) up front so the existence check below validates
|
|
# the same ids the store persists. The store re-normalizes (idempotent); doing
|
|
# it here too keeps the raw-vs-stored mismatch from rejecting a padded-but-valid
|
|
# id as non-existent.
|
|
item_ids = validate_item_ids_for_attach(list(request_message.item_ids))
|
|
kwargs: dict[str, object] = {"item_ids": item_ids}
|
|
if (
|
|
request_message.HasField("item_type")
|
|
and request_message.item_type != REVIEW_ITEM_TYPE_UNSPECIFIED
|
|
):
|
|
kwargs["item_type"] = ReviewItemType.from_proto(request_message.item_type)
|
|
# Items are trace references with no DB foreign key, so verify every trace
|
|
# exists in the queue's experiment before attaching. A missing or
|
|
# cross-experiment id would otherwise become a ghost PENDING item the UI
|
|
# can't render, and reviewing it would leak traces across experiments.
|
|
queue = store.get_review_queue(request_message.queue_id)
|
|
experiment_by_trace = {
|
|
info.trace_id: str(info.experiment_id) for info in store.batch_get_trace_infos(item_ids)
|
|
}
|
|
if invalid := [i for i in item_ids if experiment_by_trace.get(i) != str(queue.experiment_id)]:
|
|
raise MlflowException(
|
|
f"Cannot attach trace(s) {invalid} to review queue '{request_message.queue_id}': "
|
|
f"they do not exist in experiment '{queue.experiment_id}'.",
|
|
error_code=RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
items = store.add_items_to_review_queue(request_message.queue_id, **kwargs)
|
|
response = AddItemsToReviewQueue.Response(items=[i.to_proto() for i in items])
|
|
return _wrap_response(response)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _remove_items_from_review_queue():
|
|
request_message = _get_request_message(
|
|
RemoveItemsFromReviewQueue(),
|
|
schema={"queue_id": [_assert_required, _assert_string]},
|
|
)
|
|
_get_tracking_store().remove_items_from_review_queue(
|
|
request_message.queue_id, item_ids=list(request_message.item_ids)
|
|
)
|
|
return _wrap_response(RemoveItemsFromReviewQueue.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _list_review_queue_items():
|
|
request_message = _get_request_message(
|
|
ListReviewQueueItems(),
|
|
schema={
|
|
"queue_id": [_assert_required, _assert_string],
|
|
"max_results": [_assert_intlike, _review_queue_max_results_validator],
|
|
},
|
|
)
|
|
max_results = request_message.max_results if request_message.HasField("max_results") else None
|
|
page_token = request_message.page_token if request_message.HasField("page_token") else None
|
|
status = None
|
|
if request_message.HasField("status") and request_message.status != REVIEW_STATUS_UNSPECIFIED:
|
|
status = ReviewStatus.from_proto(request_message.status)
|
|
items = _get_tracking_store().list_review_queue_items(
|
|
request_message.queue_id,
|
|
status=status,
|
|
max_results=max_results,
|
|
page_token=page_token,
|
|
)
|
|
response = ListReviewQueueItems.Response(
|
|
items=[i.to_proto() for i in items],
|
|
next_page_token=items.token or "",
|
|
)
|
|
return _wrap_response(response)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _set_review_queue_item_status():
|
|
request_message = _get_request_message(
|
|
SetReviewQueueItemStatus(),
|
|
schema={
|
|
"queue_id": [_assert_required, _assert_string],
|
|
"item_id": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
# `status` is intentionally not in the input schema above: rejection of an
|
|
# absent/UNSPECIFIED status is delegated to `ReviewStatus.from_proto` (a
|
|
# required-field schema entry would only check HasField, not enum value).
|
|
status = ReviewStatus.from_proto(request_message.status)
|
|
# `completed_by` is attribution: bind it to the authenticated caller, never
|
|
# the client value (which could spoof another user). Reopening to `pending`
|
|
# clears attribution, so it stays unset there. On a no-auth server there's no
|
|
# identity to bind to (a single `default` user), so accept the client value.
|
|
username = _get_request_username()
|
|
if username is not None:
|
|
completed_by = None if status == ReviewStatus.PENDING else username
|
|
else:
|
|
completed_by = (
|
|
request_message.completed_by if request_message.HasField("completed_by") else None
|
|
)
|
|
item = _get_tracking_store().set_review_queue_item_status(
|
|
request_message.queue_id,
|
|
item_id=request_message.item_id,
|
|
status=status,
|
|
completed_by=completed_by,
|
|
)
|
|
return _wrap_response(SetReviewQueueItemStatus.Response(item=item.to_proto()))
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _invoke_issue_detection_handler():
|
|
"""
|
|
Invoke issue detection on traces asynchronously.
|
|
|
|
This is a UI-only AJAX endpoint for running issue detection from the frontend.
|
|
"""
|
|
from mlflow.genai.discovery.job import _fetch_provider_credentials, invoke_issue_detection_job
|
|
from mlflow.server.jobs import submit_job
|
|
|
|
_validate_content_type(request, ["application/json"])
|
|
|
|
request_json = _get_validated_flask_request_json(
|
|
schema={
|
|
"experiment_id": [_assert_required, _assert_string],
|
|
"trace_ids": [_assert_required, _assert_array],
|
|
"categories": [_assert_required, _assert_array],
|
|
"provider": [_assert_required, _assert_string],
|
|
"model": [_assert_string],
|
|
"secret_id": [_assert_string],
|
|
"endpoint_name": [_assert_string],
|
|
}
|
|
)
|
|
|
|
experiment_id = request_json.get("experiment_id")
|
|
trace_ids = request_json.get("trace_ids", [])
|
|
categories = request_json.get("categories", [])
|
|
provider = request_json.get("provider")
|
|
model = request_json.get("model")
|
|
secret_id = request_json.get("secret_id")
|
|
endpoint_name = request_json.get("endpoint_name")
|
|
|
|
if not endpoint_name and not (provider and model):
|
|
raise MlflowException(
|
|
"Either 'endpoint_name' or both 'provider' and 'model' must be provided"
|
|
)
|
|
|
|
# Fetch credentials required for executing the job
|
|
if secret_id:
|
|
store = _get_tracking_store()
|
|
credentials = _fetch_provider_credentials(store, provider, secret_id)
|
|
else:
|
|
credentials = None
|
|
|
|
# Create the run upfront so we can return run_id immediately
|
|
model_name = f"gateway:/{endpoint_name}" if endpoint_name else f"{provider}:/{model}"
|
|
tags = {
|
|
MLFLOW_RUN_TYPE: MLFLOW_RUN_TYPE_ISSUE_DETECTION,
|
|
"categories": ",".join(categories),
|
|
"model": model_name,
|
|
"total_traces": len(trace_ids),
|
|
}
|
|
if endpoint_name:
|
|
tags["endpoint_name"] = endpoint_name
|
|
run = mlflow.start_run(
|
|
experiment_id=experiment_id,
|
|
tags=tags,
|
|
)
|
|
run_id = run.info.run_id
|
|
|
|
job = submit_job(
|
|
function=invoke_issue_detection_job,
|
|
params={
|
|
"experiment_id": experiment_id,
|
|
"trace_ids": trace_ids,
|
|
"categories": categories,
|
|
"run_id": run_id,
|
|
"model": model_name,
|
|
},
|
|
extra_envs=credentials,
|
|
)
|
|
# Tag the run with job ID for later retrieval
|
|
mlflow.set_tag(MLFLOW_ISSUE_DETECTION_JOB_ID, job.job_id)
|
|
mlflow.end_run(RunStatus.to_string(RunStatus.RUNNING))
|
|
|
|
return jsonify({"job_id": job.job_id, "run_id": run_id})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _invoke_genai_evaluate_handler():
|
|
"""
|
|
Run mlflow.genai.evaluate(...) against the chosen traces + scorers as an
|
|
async job, attached to a brand-new MLflow eval run.
|
|
|
|
This is a UI-only AJAX endpoint that backs the "Run evaluation" feature on
|
|
the Evaluation Runs page.
|
|
"""
|
|
from mlflow.genai.evaluation.job import invoke_genai_evaluate_job
|
|
from mlflow.server.jobs import submit_job
|
|
|
|
_validate_content_type(request, ["application/json"])
|
|
|
|
request_json = _get_validated_flask_request_json(
|
|
schema={
|
|
"experiment_id": [_assert_required, _assert_string],
|
|
"trace_ids": [_assert_required, _assert_array],
|
|
"serialized_scorers": [_assert_required, _assert_array],
|
|
}
|
|
)
|
|
|
|
experiment_id = request_json["experiment_id"]
|
|
trace_ids = request_json["trace_ids"]
|
|
serialized_scorers = request_json["serialized_scorers"]
|
|
|
|
if not trace_ids:
|
|
raise MlflowException(
|
|
"Please select at least one trace to evaluate.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
if not serialized_scorers:
|
|
raise MlflowException(
|
|
"Please select at least one judge.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
# Create the run upfront so we can return run_id immediately, so the run
|
|
# shows up on /evaluation-runs even before the job has produced artifacts.
|
|
tags = {MLFLOW_RUN_TYPE: MLFLOW_RUN_TYPE_GENAI_EVALUATE}
|
|
client = MlflowClient()
|
|
run = client.create_run(experiment_id=experiment_id, tags=tags)
|
|
run_id = run.info.run_id
|
|
|
|
username = request.authorization.username if request.authorization else None
|
|
|
|
try:
|
|
job = submit_job(
|
|
function=invoke_genai_evaluate_job,
|
|
params={
|
|
"trace_ids": trace_ids,
|
|
"serialized_scorers": serialized_scorers,
|
|
"run_id": run_id,
|
|
"username": username,
|
|
},
|
|
)
|
|
client.set_tag(run_id, MLFLOW_GENAI_EVALUATE_JOB_ID, job.job_id)
|
|
except Exception:
|
|
client.set_terminated(run_id, RunStatus.to_string(RunStatus.FAILED))
|
|
raise
|
|
|
|
return jsonify({"job_id": job.job_id, "run_id": run_id})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_job(job_id):
|
|
from mlflow.server.jobs import get_job
|
|
|
|
job = get_job(job_id)
|
|
return jsonify({
|
|
"status": str(job.status),
|
|
"result": job.parsed_result,
|
|
"status_details": job.status_details,
|
|
})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _cancel_job(job_id):
|
|
from mlflow.server.jobs import cancel_job
|
|
|
|
job = cancel_job(job_id)
|
|
return jsonify({
|
|
"status": str(job.status),
|
|
"result": job.parsed_result,
|
|
})
|
|
|
|
|
|
# Deprecated MLflow Tracing APIs. Kept for backward compatibility but do not use.
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _deprecated_start_trace_v2():
|
|
"""
|
|
A request handler for `POST /mlflow/traces` to create a new TraceInfo record in tracking store.
|
|
"""
|
|
request_message = _get_request_message(
|
|
StartTrace(),
|
|
schema={
|
|
"experiment_id": [_assert_string],
|
|
"timestamp_ms": [_assert_intlike],
|
|
"request_metadata": [_assert_map_key_present],
|
|
"tags": [_assert_map_key_present],
|
|
},
|
|
)
|
|
request_metadata = {e.key: e.value for e in request_message.request_metadata}
|
|
tags = {e.key: e.value for e in request_message.tags}
|
|
|
|
trace_info = _get_tracking_store().deprecated_start_trace_v2(
|
|
experiment_id=request_message.experiment_id,
|
|
timestamp_ms=request_message.timestamp_ms,
|
|
request_metadata=request_metadata,
|
|
tags=tags,
|
|
)
|
|
response_message = StartTrace.Response(trace_info=trace_info.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _deprecated_end_trace_v2(request_id):
|
|
"""
|
|
A request handler for `PATCH /mlflow/traces/{request_id}` to mark an existing TraceInfo
|
|
record completed in tracking store.
|
|
"""
|
|
request_message = _get_request_message(
|
|
EndTrace(),
|
|
schema={
|
|
"timestamp_ms": [_assert_intlike],
|
|
"status": [_assert_string],
|
|
"request_metadata": [_assert_map_key_present],
|
|
"tags": [_assert_map_key_present],
|
|
},
|
|
)
|
|
request_metadata = {e.key: e.value for e in request_message.request_metadata}
|
|
tags = {e.key: e.value for e in request_message.tags}
|
|
|
|
trace_info = _get_tracking_store().deprecated_end_trace_v2(
|
|
request_id=request_id,
|
|
timestamp_ms=request_message.timestamp_ms,
|
|
status=TraceStatus.from_proto(request_message.status),
|
|
request_metadata=request_metadata,
|
|
tags=tags,
|
|
)
|
|
|
|
if isinstance(trace_info, TraceInfo):
|
|
trace_info = TraceInfoV2.from_v3(trace_info)
|
|
|
|
response_message = EndTrace.Response(trace_info=trace_info.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _deprecated_get_trace_info_v2(request_id):
|
|
"""
|
|
A request handler for `GET /mlflow/traces/{request_id}/info` to retrieve
|
|
an existing TraceInfo record from tracking store.
|
|
"""
|
|
trace_info = _get_tracking_store().get_trace_info(request_id)
|
|
trace_info = TraceInfoV2.from_v3(trace_info)
|
|
response_message = GetTraceInfo.Response(trace_info=trace_info.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _deprecated_search_traces_v2():
|
|
"""
|
|
A request handler for `GET /mlflow/traces` to search for TraceInfo records in tracking store.
|
|
"""
|
|
request_message = _get_request_message(
|
|
SearchTraces(),
|
|
schema={
|
|
"experiment_ids": [
|
|
_assert_array,
|
|
_assert_item_type_string,
|
|
_assert_required,
|
|
],
|
|
"filter": [_assert_string],
|
|
"max_results": [
|
|
_assert_intlike,
|
|
lambda x: _assert_less_than_or_equal(int(x), 500),
|
|
],
|
|
"order_by": [_assert_array, _assert_item_type_string],
|
|
"page_token": [_assert_string],
|
|
},
|
|
)
|
|
|
|
traces, token = _get_tracking_store().search_traces(
|
|
experiment_ids=request_message.experiment_ids,
|
|
filter_string=request_message.filter,
|
|
max_results=request_message.max_results,
|
|
order_by=request_message.order_by,
|
|
page_token=request_message.page_token or None,
|
|
)
|
|
traces = [TraceInfoV2.from_v3(t) for t in traces]
|
|
response_message = SearchTraces.Response()
|
|
response_message.traces.extend([e.to_proto() for e in traces])
|
|
if token:
|
|
response_message.next_page_token = token
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
# Logged Models APIs
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def get_logged_model_artifact_handler(model_id: str):
|
|
artifact_file_path = request.args.get("artifact_file_path")
|
|
if not artifact_file_path:
|
|
raise MlflowException(
|
|
'Request must include the "artifact_file_path" query parameter.',
|
|
error_code=BAD_REQUEST,
|
|
)
|
|
validate_path_is_safe(artifact_file_path)
|
|
|
|
logged_model: LoggedModel = _get_tracking_store().get_logged_model(model_id)
|
|
if _is_servable_proxied_run_artifact_root(logged_model.artifact_location):
|
|
artifact_repo = _get_artifact_repo_mlflow_artifacts()
|
|
artifact_path = _get_proxied_run_artifact_destination_path(
|
|
proxied_artifact_root=logged_model.artifact_location,
|
|
relative_path=artifact_file_path,
|
|
)
|
|
artifact_path = _get_workspace_scoped_repo_path_if_enabled(artifact_path)
|
|
else:
|
|
artifact_repo = get_artifact_repository(logged_model.artifact_location)
|
|
artifact_path = artifact_file_path
|
|
|
|
return _send_artifact(artifact_repo, artifact_path)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _create_logged_model():
|
|
request_message = _get_request_message(
|
|
CreateLoggedModel(),
|
|
schema={
|
|
"experiment_id": [_assert_string, _assert_required],
|
|
"name": [_assert_string],
|
|
"model_type": [_assert_string],
|
|
"source_run_id": [_assert_string],
|
|
"params": [_assert_array],
|
|
"tags": [_assert_array],
|
|
},
|
|
)
|
|
|
|
model = _get_tracking_store().create_logged_model(
|
|
experiment_id=request_message.experiment_id,
|
|
name=request_message.name or None,
|
|
model_type=request_message.model_type,
|
|
source_run_id=request_message.source_run_id,
|
|
params=(
|
|
[LoggedModelParameter.from_proto(param) for param in request_message.params]
|
|
if request_message.params
|
|
else None
|
|
),
|
|
tags=(
|
|
[LoggedModelTag(key=tag.key, value=tag.value) for tag in request_message.tags]
|
|
if request_message.tags
|
|
else None
|
|
),
|
|
)
|
|
response_message = CreateLoggedModel.Response(model=model.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _log_logged_model_params(model_id: str):
|
|
request_message = _get_request_message(
|
|
LogLoggedModelParamsRequest(),
|
|
schema={
|
|
"model_id": [_assert_string, _assert_required],
|
|
"params": [_assert_array],
|
|
},
|
|
)
|
|
params = (
|
|
[LoggedModelParameter.from_proto(param) for param in request_message.params]
|
|
if request_message.params
|
|
else []
|
|
)
|
|
_get_tracking_store().log_logged_model_params(model_id, params)
|
|
return _wrap_response(LogLoggedModelParamsRequest.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_logged_model(model_id: str):
|
|
allow_deleted = request.args.get("allow_deleted", "false").lower() == "true"
|
|
model = _get_tracking_store().get_logged_model(model_id, allow_deleted=allow_deleted)
|
|
response_message = GetLoggedModel.Response(model=model.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _finalize_logged_model(model_id: str):
|
|
request_message = _get_request_message(
|
|
FinalizeLoggedModel(),
|
|
schema={
|
|
"model_id": [_assert_string, _assert_required],
|
|
"status": [_assert_intlike, _assert_required],
|
|
},
|
|
)
|
|
model = _get_tracking_store().finalize_logged_model(
|
|
request_message.model_id, LoggedModelStatus.from_int(request_message.status)
|
|
)
|
|
response_message = FinalizeLoggedModel.Response(model=model.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_logged_model(model_id: str):
|
|
_get_tracking_store().delete_logged_model(model_id)
|
|
return _wrap_response(DeleteLoggedModel.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _set_logged_model_tags(model_id: str):
|
|
request_message = _get_request_message(
|
|
SetLoggedModelTags(),
|
|
schema={"tags": [_assert_array]},
|
|
)
|
|
tags = [LoggedModelTag(key=tag.key, value=tag.value) for tag in request_message.tags]
|
|
_get_tracking_store().set_logged_model_tags(model_id, tags)
|
|
return _wrap_response(SetLoggedModelTags.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_logged_model_tag(model_id: str, tag_key: str):
|
|
_get_tracking_store().delete_logged_model_tag(model_id, tag_key)
|
|
return _wrap_response(DeleteLoggedModelTag.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _search_logged_models():
|
|
request_message = _get_request_message(
|
|
SearchLoggedModels(),
|
|
schema={
|
|
"experiment_ids": [
|
|
_assert_array,
|
|
_assert_item_type_string,
|
|
_assert_required,
|
|
],
|
|
"filter": [_assert_string],
|
|
"datasets": [_assert_array],
|
|
"max_results": [_assert_intlike],
|
|
"order_by": [_assert_array],
|
|
"page_token": [_assert_string],
|
|
},
|
|
)
|
|
models = _get_tracking_store().search_logged_models(
|
|
# Convert `RepeatedScalarContainer` objects (experiment_ids and order_by) to `list`
|
|
# to avoid serialization issues
|
|
experiment_ids=list(request_message.experiment_ids),
|
|
filter_string=request_message.filter or None,
|
|
datasets=(
|
|
[
|
|
{
|
|
"dataset_name": d.dataset_name,
|
|
"dataset_digest": d.dataset_digest or None,
|
|
}
|
|
for d in request_message.datasets
|
|
]
|
|
if request_message.datasets
|
|
else None
|
|
),
|
|
max_results=request_message.max_results or None,
|
|
order_by=(
|
|
[
|
|
{
|
|
"field_name": ob.field_name,
|
|
"ascending": ob.ascending,
|
|
"dataset_name": ob.dataset_name or None,
|
|
"dataset_digest": ob.dataset_digest or None,
|
|
}
|
|
for ob in request_message.order_by
|
|
]
|
|
if request_message.order_by
|
|
else None
|
|
),
|
|
page_token=request_message.page_token or None,
|
|
)
|
|
response_message = SearchLoggedModels.Response()
|
|
response_message.models.extend([e.to_proto() for e in models])
|
|
if models.token:
|
|
response_message.next_page_token = models.token
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _list_logged_model_artifacts(model_id: str):
|
|
request_message = _get_request_message(
|
|
ListLoggedModelArtifacts(),
|
|
schema={"artifact_directory_path": [_assert_string]},
|
|
)
|
|
if request_message.HasField("artifact_directory_path"):
|
|
artifact_path = validate_path_is_safe(request_message.artifact_directory_path)
|
|
else:
|
|
artifact_path = None
|
|
|
|
return _list_logged_model_artifacts_impl(model_id, artifact_path)
|
|
|
|
|
|
def _list_logged_model_artifacts_impl(
|
|
model_id: str, artifact_directory_path: str | None
|
|
) -> Response:
|
|
response = ListLoggedModelArtifacts.Response()
|
|
logged_model: LoggedModel = _get_tracking_store().get_logged_model(model_id)
|
|
if _is_servable_proxied_run_artifact_root(logged_model.artifact_location):
|
|
artifacts = _list_artifacts_for_proxied_run_artifact_root(
|
|
proxied_artifact_root=logged_model.artifact_location,
|
|
relative_path=artifact_directory_path,
|
|
)
|
|
else:
|
|
artifacts = get_artifact_repository(logged_model.artifact_location).list_artifacts(
|
|
artifact_directory_path
|
|
)
|
|
|
|
response.files.extend([a.to_proto() for a in artifacts])
|
|
response.root_uri = logged_model.artifact_location
|
|
return _wrap_response(response)
|
|
|
|
|
|
# =============================================================================
|
|
# Scorer Management Handlers
|
|
# =============================================================================
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _register_scorer():
|
|
request_message = _get_request_message(
|
|
RegisterScorer(),
|
|
schema={
|
|
"experiment_id": [_assert_required, _assert_string],
|
|
"name": [_assert_required, _assert_string],
|
|
"serialized_scorer": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
# Decorator scorers contain a `call_source` field that is executed via exec() during
|
|
# deserialization. The Python client blocks this via `_check_can_be_registered()`, but
|
|
# that check is client-side only and can be bypassed by calling the REST API directly.
|
|
# Enforce the same restriction here in the server handler so it applies regardless of
|
|
# how the request arrives.
|
|
try:
|
|
serialized_data = json.loads(request_message.serialized_scorer)
|
|
except json.JSONDecodeError as e:
|
|
raise MlflowException.invalid_parameter_value("serialized_scorer must be valid JSON") from e
|
|
if serialized_data.get("call_source") is not None:
|
|
raise MlflowException.invalid_parameter_value(
|
|
DECORATOR_SCORER_REGISTRATION_NOT_SUPPORTED_ERROR
|
|
)
|
|
scorer_version = _get_tracking_store().register_scorer(
|
|
request_message.experiment_id,
|
|
request_message.name,
|
|
request_message.serialized_scorer,
|
|
)
|
|
response_message = RegisterScorer.Response()
|
|
response_message.version = scorer_version.scorer_version
|
|
response_message.scorer_id = scorer_version.scorer_id
|
|
response_message.experiment_id = scorer_version.experiment_id
|
|
response_message.name = scorer_version.scorer_name
|
|
response_message.serialized_scorer = scorer_version._serialized_scorer
|
|
response_message.creation_time = scorer_version.creation_time
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _list_scorers():
|
|
request_message = _get_request_message(
|
|
ListScorers(),
|
|
schema={"experiment_id": [_assert_string]},
|
|
)
|
|
response_message = ListScorers.Response()
|
|
store = _get_tracking_store()
|
|
if request_message.experiment_id:
|
|
scorers = store.list_scorers(request_message.experiment_id)
|
|
else:
|
|
# Cross-experiment listing: walk the active workspace's experiments
|
|
# via the workspace-aware ``search_experiments`` pagination, then
|
|
# batch the scorer fetch through ``list_scorers_across_experiments``.
|
|
# Auth-side ``filter_list_scorers`` applies per-row RBAC filtering on
|
|
# the response.
|
|
experiment_ids: list[str] = []
|
|
page_token: str | None = None
|
|
while True:
|
|
page = store.search_experiments(
|
|
view_type=ViewType.ACTIVE_ONLY,
|
|
max_results=1000,
|
|
page_token=page_token,
|
|
)
|
|
experiment_ids.extend(e.experiment_id for e in page)
|
|
if not (page_token := page.token):
|
|
break
|
|
scorers = store.list_scorers_across_experiments(experiment_ids)
|
|
response_message.scorers.extend([scorer.to_proto() for scorer in scorers])
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _list_scorer_versions():
|
|
request_message = _get_request_message(
|
|
ListScorerVersions(),
|
|
schema={
|
|
"experiment_id": [_assert_required, _assert_string],
|
|
"name": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
response_message = ListScorerVersions.Response()
|
|
scorers = _get_tracking_store().list_scorer_versions(
|
|
request_message.experiment_id, request_message.name
|
|
)
|
|
response_message.scorers.extend([scorer.to_proto() for scorer in scorers])
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_scorer():
|
|
request_message = _get_request_message(
|
|
GetScorer(),
|
|
schema={
|
|
"experiment_id": [_assert_required, _assert_string],
|
|
"name": [_assert_required, _assert_string],
|
|
"version": [_assert_intlike],
|
|
},
|
|
)
|
|
response_message = GetScorer.Response()
|
|
scorer_version = _get_tracking_store().get_scorer(
|
|
request_message.experiment_id,
|
|
request_message.name,
|
|
request_message.version if request_message.HasField("version") else None,
|
|
)
|
|
response_message.scorer.CopyFrom(scorer_version.to_proto())
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_scorer():
|
|
request_message = _get_request_message(
|
|
DeleteScorer(),
|
|
schema={
|
|
"experiment_id": [_assert_required, _assert_string],
|
|
"name": [_assert_required, _assert_string],
|
|
"version": [_assert_intlike],
|
|
},
|
|
)
|
|
_get_tracking_store().delete_scorer(
|
|
request_message.experiment_id,
|
|
request_message.name,
|
|
request_message.version if request_message.HasField("version") else None,
|
|
)
|
|
response_message = DeleteScorer.Response()
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_online_scoring_configs():
|
|
"""
|
|
Get online scoring configurations for a list of scorer IDs.
|
|
|
|
Query Parameters:
|
|
scorer_ids: List of scorer IDs to fetch configurations for.
|
|
|
|
Returns:
|
|
JSON response containing a list of configurations.
|
|
"""
|
|
request_json = _get_validated_flask_request_json(
|
|
flask_request=request,
|
|
schema={
|
|
"scorer_ids": [_assert_required, _assert_array, _assert_item_type_string],
|
|
},
|
|
)
|
|
|
|
scorer_ids = request_json["scorer_ids"]
|
|
configs = _get_tracking_store().get_online_scoring_configs(scorer_ids)
|
|
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(json.dumps({"configs": [c.to_dict() for c in configs]}))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _upsert_online_scoring_config():
|
|
"""
|
|
Update the online scoring configuration for a registered scorer.
|
|
|
|
Request Body (JSON):
|
|
experiment_id: The ID of the Experiment containing the scorer.
|
|
name: The scorer name.
|
|
sample_rate: The sampling rate (0.0 to 1.0).
|
|
filter_string: Optional filter string for trace selection.
|
|
|
|
Returns:
|
|
JSON response containing the updated configuration.
|
|
"""
|
|
request_json = _get_validated_flask_request_json(
|
|
flask_request=request,
|
|
schema={
|
|
"experiment_id": [_assert_required, _assert_string],
|
|
"name": [_assert_required, _assert_string],
|
|
"sample_rate": [_assert_required],
|
|
"filter_string": [],
|
|
},
|
|
)
|
|
|
|
filter_string = request_json.get("filter_string")
|
|
if filter_string is not None and not isinstance(filter_string, str):
|
|
raise MlflowException(
|
|
f"Invalid value {filter_string!r} for parameter 'filter_string' supplied: "
|
|
f"Value was of type '{type(filter_string).__name__}'. "
|
|
"Expected type 'str' or None.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
config = _get_tracking_store().upsert_online_scoring_config(
|
|
experiment_id=request_json["experiment_id"],
|
|
scorer_name=request_json["name"],
|
|
sample_rate=float(request_json["sample_rate"]),
|
|
filter_string=filter_string,
|
|
)
|
|
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(json.dumps({"config": config.to_dict()}))
|
|
return response
|
|
|
|
|
|
# =============================================================================
|
|
# Secrets Management Handlers
|
|
# =============================================================================
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _create_gateway_secret():
|
|
request_message = _get_request_message(
|
|
CreateGatewaySecret(),
|
|
schema={
|
|
"secret_name": [_assert_required, _assert_string],
|
|
"secret_value": [_assert_secret_value],
|
|
"provider": [_assert_string],
|
|
"created_by": [_assert_string],
|
|
},
|
|
)
|
|
# Empty map means no auth_config was provided
|
|
auth_config = dict(request_message.auth_config) or None
|
|
|
|
secret = _get_tracking_store().create_gateway_secret(
|
|
secret_name=request_message.secret_name,
|
|
secret_value=dict(request_message.secret_value),
|
|
provider=request_message.provider or None,
|
|
auth_config=auth_config,
|
|
created_by=request_message.created_by or None,
|
|
)
|
|
response_message = CreateGatewaySecret.Response()
|
|
response_message.secret.CopyFrom(secret.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_gateway_secret_info():
|
|
request_message = _get_request_message(
|
|
GetGatewaySecretInfo(),
|
|
schema={
|
|
"secret_id": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
secret = _get_tracking_store().get_secret_info(request_message.secret_id)
|
|
response_message = GetGatewaySecretInfo.Response()
|
|
response_message.secret.CopyFrom(secret.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _update_gateway_secret():
|
|
request_message = _get_request_message(
|
|
UpdateGatewaySecret(),
|
|
schema={
|
|
"secret_id": [_assert_required, _assert_string],
|
|
"updated_by": [_assert_string],
|
|
},
|
|
)
|
|
# Empty map means no auth_config was provided
|
|
auth_config = dict(request_message.auth_config) or None
|
|
|
|
# Empty map means no update to secret_value
|
|
secret_value = dict(request_message.secret_value) or None
|
|
|
|
secret = _get_tracking_store().update_gateway_secret(
|
|
secret_id=request_message.secret_id,
|
|
secret_value=secret_value,
|
|
auth_config=auth_config,
|
|
updated_by=request_message.updated_by or None,
|
|
)
|
|
response_message = UpdateGatewaySecret.Response()
|
|
response_message.secret.CopyFrom(secret.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_gateway_secret():
|
|
request_message = _get_request_message(
|
|
DeleteGatewaySecret(),
|
|
schema={
|
|
"secret_id": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
_get_tracking_store().delete_gateway_secret(request_message.secret_id)
|
|
response_message = DeleteGatewaySecret.Response()
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _list_gateway_secrets():
|
|
request_message = _get_request_message(
|
|
ListGatewaySecretInfos(),
|
|
schema={
|
|
"provider": [_assert_string],
|
|
},
|
|
)
|
|
secrets = _get_tracking_store().list_secret_infos(
|
|
provider=request_message.provider or None,
|
|
)
|
|
response_message = ListGatewaySecretInfos.Response()
|
|
response_message.secrets.extend([s.to_proto() for s in secrets])
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
# =============================================================================
|
|
# Endpoints Management Handlers
|
|
# =============================================================================
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _create_gateway_endpoint():
|
|
request_message = _get_request_message(
|
|
CreateGatewayEndpoint(),
|
|
schema={
|
|
"name": [_assert_required, _assert_string],
|
|
"created_by": [_assert_string],
|
|
"model_configs": [_assert_required],
|
|
"routing_strategy": [_assert_string],
|
|
},
|
|
)
|
|
if request_message.name and not is_valid_endpoint_name(request_message.name):
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Invalid endpoint name '{request_message.name}'. "
|
|
"Name can only contain letters, numbers, underscores, hyphens, and dots."
|
|
)
|
|
# Convert proto fallback_config to entity FallbackConfig
|
|
fallback_config = None
|
|
if request_message.HasField("fallback_config"):
|
|
fallback_config = FallbackConfig(
|
|
strategy=FallbackStrategy.from_proto(request_message.fallback_config.strategy)
|
|
if request_message.fallback_config.HasField("strategy")
|
|
else None,
|
|
max_attempts=request_message.fallback_config.max_attempts
|
|
if request_message.fallback_config.HasField("max_attempts")
|
|
else None,
|
|
)
|
|
|
|
model_configs = [
|
|
GatewayEndpointModelConfig.from_proto(config) for config in request_message.model_configs
|
|
]
|
|
|
|
# Determine experiment_id and usage_tracking
|
|
experiment_id = (
|
|
request_message.experiment_id if request_message.HasField("experiment_id") else None
|
|
)
|
|
usage_tracking = (
|
|
request_message.usage_tracking if request_message.HasField("usage_tracking") else True
|
|
)
|
|
|
|
endpoint = _get_tracking_store().create_gateway_endpoint(
|
|
name=request_message.name or None,
|
|
model_configs=model_configs,
|
|
created_by=request_message.created_by or None,
|
|
routing_strategy=RoutingStrategyEntity.from_proto(request_message.routing_strategy)
|
|
if request_message.HasField("routing_strategy")
|
|
else None,
|
|
fallback_config=fallback_config,
|
|
experiment_id=experiment_id,
|
|
usage_tracking=usage_tracking,
|
|
)
|
|
response_message = CreateGatewayEndpoint.Response()
|
|
response_message.endpoint.CopyFrom(endpoint.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_gateway_endpoint():
|
|
request_message = _get_request_message(
|
|
GetGatewayEndpoint(),
|
|
schema={
|
|
"endpoint_id": [_assert_string],
|
|
"name": [_assert_string],
|
|
},
|
|
)
|
|
endpoint = _get_tracking_store().get_gateway_endpoint(
|
|
endpoint_id=request_message.endpoint_id or None,
|
|
name=request_message.name or None,
|
|
)
|
|
response_message = GetGatewayEndpoint.Response()
|
|
response_message.endpoint.CopyFrom(endpoint.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _update_gateway_endpoint():
|
|
request_message = _get_request_message(
|
|
UpdateGatewayEndpoint(),
|
|
schema={
|
|
"endpoint_id": [_assert_required, _assert_string],
|
|
"name": [_assert_string],
|
|
"updated_by": [_assert_string],
|
|
"routing_strategy": [_assert_string],
|
|
},
|
|
)
|
|
if request_message.name and not is_valid_endpoint_name(request_message.name):
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Invalid endpoint name '{request_message.name}'. "
|
|
"Name can only contain letters, numbers, underscores, hyphens, and dots."
|
|
)
|
|
# Convert proto fallback_config to entity FallbackConfig
|
|
fallback_config = None
|
|
if request_message.HasField("fallback_config"):
|
|
fallback_config = FallbackConfig(
|
|
strategy=FallbackStrategy.from_proto(request_message.fallback_config.strategy)
|
|
if request_message.fallback_config.HasField("strategy")
|
|
else None,
|
|
max_attempts=request_message.fallback_config.max_attempts
|
|
if request_message.fallback_config.HasField("max_attempts")
|
|
else None,
|
|
)
|
|
|
|
# Convert proto model_configs to entity GatewayEndpointModelConfig list
|
|
model_configs = None
|
|
if request_message.model_configs:
|
|
model_configs = [
|
|
GatewayEndpointModelConfig.from_proto(config)
|
|
for config in request_message.model_configs
|
|
]
|
|
|
|
# Determine experiment_id and usage_tracking
|
|
experiment_id = (
|
|
request_message.experiment_id if request_message.HasField("experiment_id") else None
|
|
)
|
|
usage_tracking = (
|
|
request_message.usage_tracking if request_message.HasField("usage_tracking") else None
|
|
)
|
|
|
|
endpoint = _get_tracking_store().update_gateway_endpoint(
|
|
endpoint_id=request_message.endpoint_id,
|
|
name=request_message.name or None,
|
|
model_configs=model_configs,
|
|
updated_by=request_message.updated_by or None,
|
|
routing_strategy=RoutingStrategyEntity.from_proto(request_message.routing_strategy)
|
|
if request_message.HasField("routing_strategy")
|
|
else None,
|
|
fallback_config=fallback_config,
|
|
experiment_id=experiment_id,
|
|
usage_tracking=usage_tracking,
|
|
)
|
|
response_message = UpdateGatewayEndpoint.Response()
|
|
response_message.endpoint.CopyFrom(endpoint.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_gateway_endpoint():
|
|
request_message = _get_request_message(
|
|
DeleteGatewayEndpoint(),
|
|
schema={
|
|
"endpoint_id": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
_get_tracking_store().delete_gateway_endpoint(request_message.endpoint_id)
|
|
response_message = DeleteGatewayEndpoint.Response()
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _list_gateway_endpoints():
|
|
request_message = _get_request_message(
|
|
ListGatewayEndpoints(),
|
|
schema={
|
|
"provider": [_assert_string],
|
|
},
|
|
)
|
|
endpoints = _get_tracking_store().list_gateway_endpoints(
|
|
provider=request_message.provider or None,
|
|
)
|
|
response_message = ListGatewayEndpoints.Response()
|
|
response_message.endpoints.extend([e.to_proto() for e in endpoints])
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
# =============================================================================
|
|
# Model Definitions Management Handlers
|
|
# =============================================================================
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _create_gateway_model_definition():
|
|
request_message = _get_request_message(
|
|
CreateGatewayModelDefinition(),
|
|
schema={
|
|
"name": [_assert_required, _assert_string],
|
|
"secret_id": [_assert_required, _assert_string],
|
|
"provider": [_assert_required, _assert_string],
|
|
"model_name": [_assert_required, _assert_string],
|
|
"created_by": [_assert_string],
|
|
},
|
|
)
|
|
model_definition = _get_tracking_store().create_gateway_model_definition(
|
|
name=request_message.name,
|
|
secret_id=request_message.secret_id,
|
|
provider=request_message.provider,
|
|
model_name=request_message.model_name,
|
|
created_by=request_message.created_by or None,
|
|
)
|
|
response_message = CreateGatewayModelDefinition.Response()
|
|
response_message.model_definition.CopyFrom(model_definition.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_gateway_model_definition():
|
|
request_message = _get_request_message(
|
|
GetGatewayModelDefinition(),
|
|
schema={
|
|
"model_definition_id": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
model_definition = _get_tracking_store().get_gateway_model_definition(
|
|
request_message.model_definition_id
|
|
)
|
|
response_message = GetGatewayModelDefinition.Response()
|
|
response_message.model_definition.CopyFrom(model_definition.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _list_gateway_model_definitions():
|
|
request_message = _get_request_message(
|
|
ListGatewayModelDefinitions(),
|
|
schema={
|
|
"provider": [_assert_string],
|
|
"secret_id": [_assert_string],
|
|
},
|
|
)
|
|
model_definitions = _get_tracking_store().list_gateway_model_definitions(
|
|
provider=request_message.provider or None,
|
|
secret_id=request_message.secret_id or None,
|
|
)
|
|
response_message = ListGatewayModelDefinitions.Response()
|
|
response_message.model_definitions.extend([m.to_proto() for m in model_definitions])
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _update_gateway_model_definition():
|
|
request_message = _get_request_message(
|
|
UpdateGatewayModelDefinition(),
|
|
schema={
|
|
"model_definition_id": [_assert_required, _assert_string],
|
|
"name": [_assert_string],
|
|
"secret_id": [_assert_string],
|
|
"model_name": [_assert_string],
|
|
"updated_by": [_assert_string],
|
|
"provider": [_assert_string],
|
|
},
|
|
)
|
|
model_definition = _get_tracking_store().update_gateway_model_definition(
|
|
model_definition_id=request_message.model_definition_id,
|
|
name=request_message.name or None,
|
|
secret_id=request_message.secret_id or None,
|
|
model_name=request_message.model_name or None,
|
|
updated_by=request_message.updated_by or None,
|
|
provider=request_message.provider or None,
|
|
)
|
|
response_message = UpdateGatewayModelDefinition.Response()
|
|
response_message.model_definition.CopyFrom(model_definition.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_gateway_model_definition():
|
|
request_message = _get_request_message(
|
|
DeleteGatewayModelDefinition(),
|
|
schema={
|
|
"model_definition_id": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
_get_tracking_store().delete_gateway_model_definition(request_message.model_definition_id)
|
|
response_message = DeleteGatewayModelDefinition.Response()
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
# =============================================================================
|
|
# Endpoint Model Mappings Management Handlers
|
|
# =============================================================================
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _attach_model_to_gateway_endpoint():
|
|
request_message = _get_request_message(
|
|
AttachModelToGatewayEndpoint(),
|
|
schema={
|
|
"endpoint_id": [_assert_required, _assert_string],
|
|
"model_config": [_assert_required],
|
|
"created_by": [_assert_string],
|
|
},
|
|
)
|
|
|
|
model_config = GatewayEndpointModelConfig.from_proto(request_message.model_config)
|
|
|
|
mapping = _get_tracking_store().attach_model_to_endpoint(
|
|
endpoint_id=request_message.endpoint_id,
|
|
model_config=model_config,
|
|
created_by=request_message.created_by or None,
|
|
)
|
|
response_message = AttachModelToGatewayEndpoint.Response()
|
|
response_message.mapping.CopyFrom(mapping.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _detach_model_from_gateway_endpoint():
|
|
request_message = _get_request_message(
|
|
DetachModelFromGatewayEndpoint(),
|
|
schema={
|
|
"endpoint_id": [_assert_required, _assert_string],
|
|
"model_definition_id": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
_get_tracking_store().detach_model_from_endpoint(
|
|
endpoint_id=request_message.endpoint_id,
|
|
model_definition_id=request_message.model_definition_id,
|
|
)
|
|
response_message = DetachModelFromGatewayEndpoint.Response()
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
# =============================================================================
|
|
# Endpoint Bindings Management Handlers
|
|
# =============================================================================
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _create_gateway_endpoint_binding():
|
|
request_message = _get_request_message(
|
|
CreateGatewayEndpointBinding(),
|
|
schema={
|
|
"endpoint_id": [_assert_required, _assert_string],
|
|
"resource_type": [_assert_required, _assert_string],
|
|
"resource_id": [_assert_required, _assert_string],
|
|
"created_by": [_assert_string],
|
|
},
|
|
)
|
|
binding = _get_tracking_store().create_endpoint_binding(
|
|
endpoint_id=request_message.endpoint_id,
|
|
resource_type=GatewayResourceType(request_message.resource_type),
|
|
resource_id=request_message.resource_id,
|
|
created_by=request_message.created_by or None,
|
|
)
|
|
response_message = CreateGatewayEndpointBinding.Response()
|
|
response_message.binding.CopyFrom(binding.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_gateway_endpoint_binding():
|
|
request_message = _get_request_message(
|
|
DeleteGatewayEndpointBinding(),
|
|
schema={
|
|
"endpoint_id": [_assert_required, _assert_string],
|
|
"resource_type": [_assert_required, _assert_string],
|
|
"resource_id": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
_get_tracking_store().delete_endpoint_binding(
|
|
endpoint_id=request_message.endpoint_id,
|
|
resource_type=request_message.resource_type,
|
|
resource_id=request_message.resource_id,
|
|
)
|
|
response_message = DeleteGatewayEndpointBinding.Response()
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _list_gateway_endpoint_bindings():
|
|
request_message = _get_request_message(
|
|
ListGatewayEndpointBindings(),
|
|
schema={
|
|
"endpoint_id": [_assert_string],
|
|
"resource_type": [_assert_string],
|
|
"resource_id": [_assert_string],
|
|
},
|
|
)
|
|
bindings = _get_tracking_store().list_endpoint_bindings(
|
|
endpoint_id=request_message.endpoint_id or None,
|
|
resource_type=request_message.resource_type or None,
|
|
resource_id=request_message.resource_id or None,
|
|
)
|
|
response_message = ListGatewayEndpointBindings.Response()
|
|
response_message.bindings.extend([b.to_proto() for b in bindings])
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _set_gateway_endpoint_tag():
|
|
request_message = _get_request_message(
|
|
SetGatewayEndpointTag(),
|
|
schema={
|
|
"endpoint_id": [_assert_required, _assert_string],
|
|
"key": [_assert_required, _assert_string],
|
|
"value": [_assert_string],
|
|
},
|
|
)
|
|
tag = GatewayEndpointTag(request_message.key, request_message.value)
|
|
_get_tracking_store().set_gateway_endpoint_tag(request_message.endpoint_id, tag)
|
|
response_message = SetGatewayEndpointTag.Response()
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_gateway_endpoint_tag():
|
|
request_message = _get_request_message(
|
|
DeleteGatewayEndpointTag(),
|
|
schema={
|
|
"endpoint_id": [_assert_required, _assert_string],
|
|
"key": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
_get_tracking_store().delete_gateway_endpoint_tag(
|
|
request_message.endpoint_id, request_message.key
|
|
)
|
|
response_message = DeleteGatewayEndpointTag.Response()
|
|
response = Response(mimetype="application/json")
|
|
response.set_data(message_to_json(response_message))
|
|
return response
|
|
|
|
|
|
# =============================================================================
|
|
# Budget Policy Management Handlers
|
|
# =============================================================================
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _create_budget_policy():
|
|
request_message = _get_request_message(
|
|
CreateGatewayBudgetPolicy(),
|
|
schema={
|
|
"budget_unit": [_assert_required],
|
|
"budget_amount": [_assert_required],
|
|
"duration": [_assert_required],
|
|
"target_scope": [_assert_required],
|
|
"budget_action": [_assert_required],
|
|
"created_by": [_assert_string],
|
|
},
|
|
)
|
|
budget_unit = BudgetUnit.from_proto(request_message.budget_unit)
|
|
if budget_unit is None:
|
|
raise MlflowException(
|
|
message=f"Invalid budget_unit: {request_message.budget_unit}",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
duration_unit = BudgetDurationUnit.from_proto(request_message.duration.unit)
|
|
if duration_unit is None:
|
|
raise MlflowException(
|
|
message=f"Invalid duration.unit: {request_message.duration.unit}",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
if request_message.duration.value <= 0:
|
|
raise MlflowException(
|
|
message=f"duration.value must be a positive integer, got "
|
|
f"{request_message.duration.value}",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
target_scope = BudgetTargetScope.from_proto(request_message.target_scope)
|
|
if target_scope is None:
|
|
raise MlflowException(
|
|
message=f"Invalid target_scope: {request_message.target_scope}",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
budget_action = BudgetAction.from_proto(request_message.budget_action)
|
|
if budget_action is None:
|
|
raise MlflowException(
|
|
message=f"Invalid budget_action: {request_message.budget_action}",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
store = _get_tracking_store()
|
|
policy = store.create_budget_policy(
|
|
budget_unit=budget_unit,
|
|
budget_amount=request_message.budget_amount,
|
|
duration=BudgetDuration(unit=duration_unit, value=request_message.duration.value),
|
|
target_scope=target_scope,
|
|
budget_action=budget_action,
|
|
created_by=request_message.created_by or None,
|
|
)
|
|
get_budget_tracker().invalidate()
|
|
maybe_refresh_budget_policies(store)
|
|
response_message = CreateGatewayBudgetPolicy.Response()
|
|
response_message.budget_policy.CopyFrom(policy.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_budget_policy():
|
|
request_message = _get_request_message(
|
|
GetGatewayBudgetPolicy(),
|
|
schema={
|
|
"budget_policy_id": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
policy = _get_tracking_store().get_budget_policy(
|
|
budget_policy_id=request_message.budget_policy_id,
|
|
)
|
|
response_message = GetGatewayBudgetPolicy.Response()
|
|
response_message.budget_policy.CopyFrom(policy.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _update_budget_policy():
|
|
request_message = _get_request_message(
|
|
UpdateGatewayBudgetPolicy(),
|
|
schema={
|
|
"budget_policy_id": [_assert_required, _assert_string],
|
|
"updated_by": [_assert_string],
|
|
},
|
|
)
|
|
budget_unit = None
|
|
if request_message.HasField("budget_unit"):
|
|
budget_unit = BudgetUnit.from_proto(request_message.budget_unit)
|
|
if budget_unit is None:
|
|
raise MlflowException(
|
|
message=f"Invalid budget_unit: {request_message.budget_unit}",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
duration = None
|
|
if request_message.HasField("duration"):
|
|
duration_unit = BudgetDurationUnit.from_proto(request_message.duration.unit)
|
|
if duration_unit is None:
|
|
raise MlflowException(
|
|
message=f"Invalid duration.unit: {request_message.duration.unit}",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
if request_message.duration.value <= 0:
|
|
raise MlflowException(
|
|
message=f"duration.value must be a positive integer, got "
|
|
f"{request_message.duration.value}",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
duration = BudgetDuration(unit=duration_unit, value=request_message.duration.value)
|
|
target_scope = None
|
|
if request_message.HasField("target_scope"):
|
|
target_scope = BudgetTargetScope.from_proto(request_message.target_scope)
|
|
if target_scope is None:
|
|
raise MlflowException(
|
|
message=f"Invalid target_scope: {request_message.target_scope}",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
budget_action = None
|
|
if request_message.HasField("budget_action"):
|
|
budget_action = BudgetAction.from_proto(request_message.budget_action)
|
|
if budget_action is None:
|
|
raise MlflowException(
|
|
message=f"Invalid budget_action: {request_message.budget_action}",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
store = _get_tracking_store()
|
|
policy = store.update_budget_policy(
|
|
budget_policy_id=request_message.budget_policy_id,
|
|
budget_unit=budget_unit,
|
|
budget_amount=request_message.budget_amount
|
|
if request_message.HasField("budget_amount")
|
|
else None,
|
|
duration=duration,
|
|
target_scope=target_scope,
|
|
budget_action=budget_action,
|
|
updated_by=request_message.updated_by or None,
|
|
)
|
|
get_budget_tracker().invalidate()
|
|
maybe_refresh_budget_policies(store)
|
|
response_message = UpdateGatewayBudgetPolicy.Response()
|
|
response_message.budget_policy.CopyFrom(policy.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_budget_policy():
|
|
request_message = _get_request_message(
|
|
DeleteGatewayBudgetPolicy(),
|
|
schema={
|
|
"budget_policy_id": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
store = _get_tracking_store()
|
|
store.delete_budget_policy(request_message.budget_policy_id)
|
|
get_budget_tracker().invalidate()
|
|
maybe_refresh_budget_policies(store)
|
|
response_message = DeleteGatewayBudgetPolicy.Response()
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _list_budget_policies():
|
|
request_message = _get_request_message(
|
|
ListGatewayBudgetPolicies(),
|
|
schema={
|
|
"max_results": [_assert_intlike],
|
|
"page_token": [_assert_string],
|
|
},
|
|
)
|
|
budget_policies = _get_tracking_store().list_budget_policies(
|
|
max_results=request_message.max_results or SEARCH_MAX_RESULTS_DEFAULT,
|
|
page_token=request_message.page_token or None,
|
|
)
|
|
response_message = ListGatewayBudgetPolicies.Response()
|
|
response_message.budget_policies.extend([p.to_proto() for p in budget_policies])
|
|
if budget_policies.token:
|
|
response_message.next_page_token = budget_policies.token
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
def _get_request_workspace_for_budget_windows():
|
|
workspace = workspace_context.get_request_workspace()
|
|
if not MLFLOW_ENABLE_WORKSPACES.get():
|
|
return workspace
|
|
|
|
if not workspace_context.is_request_workspace_resolved():
|
|
raise MlflowException(
|
|
"A request workspace must be provided when workspaces are enabled.",
|
|
BAD_REQUEST,
|
|
)
|
|
|
|
return workspace
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _list_budget_windows():
|
|
_get_request_message(ListGatewayBudgetWindows())
|
|
workspace = _get_request_workspace_for_budget_windows()
|
|
store = _get_tracking_store()
|
|
maybe_refresh_budget_policies(store)
|
|
windows = get_budget_tracker().get_all_windows()
|
|
if workspace is not None:
|
|
windows = [w for w in windows if _policy_applies(w.policy, workspace)]
|
|
response_message = ListGatewayBudgetWindows.Response()
|
|
for w in windows:
|
|
window_msg = ListGatewayBudgetWindows.BudgetWindow(
|
|
budget_policy_id=w.policy.budget_policy_id,
|
|
window_start_ms=int(w.window_start.timestamp() * 1000),
|
|
window_end_ms=int(w.window_end.timestamp() * 1000),
|
|
current_spend=w.cumulative_spend,
|
|
)
|
|
response_message.windows.append(window_msg)
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _create_gateway_guardrail():
|
|
request_message = _get_request_message(
|
|
CreateGatewayGuardrail(),
|
|
schema={
|
|
"name": [_assert_required, _assert_string],
|
|
"scorer_id": [_assert_required, _assert_string],
|
|
"scorer_version": [_assert_required, _assert_intlike],
|
|
"stage": [_assert_required],
|
|
"action": [_assert_required],
|
|
"action_endpoint_id": [_assert_string],
|
|
},
|
|
)
|
|
from mlflow.entities.gateway_guardrail import GuardrailAction, GuardrailStage
|
|
|
|
stage = GuardrailStage.from_proto(request_message.stage)
|
|
if stage is None:
|
|
raise MlflowException(
|
|
message=f"Invalid stage: {request_message.stage}",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
action = GuardrailAction.from_proto(request_message.action)
|
|
if action is None:
|
|
raise MlflowException(
|
|
message=f"Invalid action: {request_message.action}",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
guardrail = _get_tracking_store().create_gateway_guardrail(
|
|
name=request_message.name,
|
|
scorer_id=request_message.scorer_id,
|
|
scorer_version=request_message.scorer_version,
|
|
stage=stage,
|
|
action=action,
|
|
action_endpoint_id=request_message.action_endpoint_id or None,
|
|
created_by=_get_user(),
|
|
)
|
|
response_message = CreateGatewayGuardrail.Response()
|
|
response_message.guardrail.CopyFrom(guardrail.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_gateway_guardrail():
|
|
request_message = _get_request_message(
|
|
GetGatewayGuardrail(),
|
|
schema={"guardrail_id": [_assert_required, _assert_string]},
|
|
)
|
|
guardrail = _get_tracking_store().get_gateway_guardrail(
|
|
guardrail_id=request_message.guardrail_id,
|
|
)
|
|
response_message = GetGatewayGuardrail.Response()
|
|
response_message.guardrail.CopyFrom(guardrail.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_gateway_guardrail():
|
|
request_message = _get_request_message(
|
|
DeleteGatewayGuardrail(),
|
|
schema={"guardrail_id": [_assert_required, _assert_string]},
|
|
)
|
|
_get_tracking_store().delete_gateway_guardrail(request_message.guardrail_id)
|
|
return _wrap_response(DeleteGatewayGuardrail.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _list_gateway_guardrails():
|
|
request_message = _get_request_message(
|
|
ListGatewayGuardrails(),
|
|
schema={
|
|
"max_results": [_assert_intlike],
|
|
"page_token": [_assert_string],
|
|
},
|
|
)
|
|
guardrails = _get_tracking_store().list_gateway_guardrails(
|
|
max_results=request_message.max_results or SEARCH_MAX_RESULTS_DEFAULT,
|
|
page_token=request_message.page_token or None,
|
|
)
|
|
response_message = ListGatewayGuardrails.Response()
|
|
response_message.guardrails.extend([g.to_proto() for g in guardrails])
|
|
if guardrails.token:
|
|
response_message.next_page_token = guardrails.token
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _add_guardrail_to_endpoint():
|
|
request_message = _get_request_message(
|
|
AddGuardrailToEndpoint(),
|
|
schema={
|
|
"endpoint_id": [_assert_required, _assert_string],
|
|
"guardrail_id": [_assert_required, _assert_string],
|
|
"execution_order": [_assert_intlike],
|
|
},
|
|
)
|
|
config = _get_tracking_store().add_guardrail_to_endpoint(
|
|
endpoint_id=request_message.endpoint_id,
|
|
guardrail_id=request_message.guardrail_id,
|
|
execution_order=(
|
|
request_message.execution_order if request_message.HasField("execution_order") else None
|
|
),
|
|
created_by=_get_user(),
|
|
)
|
|
response_message = AddGuardrailToEndpoint.Response()
|
|
response_message.config.CopyFrom(config.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _remove_guardrail_from_endpoint():
|
|
request_message = _get_request_message(
|
|
RemoveGuardrailFromEndpoint(),
|
|
schema={
|
|
"endpoint_id": [_assert_required, _assert_string],
|
|
"guardrail_id": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
_get_tracking_store().remove_guardrail_from_endpoint(
|
|
endpoint_id=request_message.endpoint_id,
|
|
guardrail_id=request_message.guardrail_id,
|
|
)
|
|
return _wrap_response(RemoveGuardrailFromEndpoint.Response())
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _list_endpoint_guardrail_configs():
|
|
request_message = _get_request_message(
|
|
ListEndpointGuardrailConfigs(),
|
|
schema={"endpoint_id": [_assert_required, _assert_string]},
|
|
)
|
|
configs = _get_tracking_store().list_endpoint_guardrail_configs(
|
|
endpoint_id=request_message.endpoint_id,
|
|
)
|
|
response_message = ListEndpointGuardrailConfigs.Response()
|
|
response_message.configs.extend([c.to_proto() for c in configs])
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def _update_endpoint_guardrail_config():
|
|
request_message = _get_request_message(
|
|
UpdateEndpointGuardrailConfig(),
|
|
schema={
|
|
"endpoint_id": [_assert_required, _assert_string],
|
|
"guardrail_id": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
kwargs = {
|
|
"endpoint_id": request_message.endpoint_id,
|
|
"guardrail_id": request_message.guardrail_id,
|
|
}
|
|
if request_message.HasField("execution_order"):
|
|
kwargs["execution_order"] = request_message.execution_order
|
|
config = _get_tracking_store().update_endpoint_guardrail_config(**kwargs)
|
|
response_message = UpdateEndpointGuardrailConfig.Response()
|
|
response_message.config.CopyFrom(config.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def _get_server_info():
|
|
from mlflow.store.tracking.file_store import FileStore
|
|
from mlflow.store.tracking.sqlalchemy_store import SqlAlchemyStore
|
|
|
|
store = _get_tracking_store()
|
|
try:
|
|
trace_archival_config = get_trace_archival_server_config()
|
|
except Exception:
|
|
_logger.warning(
|
|
"Failed to load trace archival config while serving server-info; "
|
|
+ "defaulting to disabled.",
|
|
exc_info=True,
|
|
)
|
|
trace_archival_config = None
|
|
trace_archival_enabled = bool(
|
|
trace_archival_config
|
|
and trace_archival_config.enabled
|
|
and _store_supports_trace_archival(store)
|
|
)
|
|
|
|
if isinstance(store, FileStore):
|
|
store_type = "FileStore"
|
|
elif isinstance(store, SqlAlchemyStore):
|
|
store_type = "SqlStore"
|
|
else:
|
|
store_type = None
|
|
return jsonify({
|
|
"store_type": store_type,
|
|
"workspaces_enabled": MLFLOW_ENABLE_WORKSPACES.get(),
|
|
"trace_archival_enabled": trace_archival_enabled,
|
|
})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _list_supported_providers():
|
|
try:
|
|
providers = get_all_providers()
|
|
return jsonify({"providers": sorted(providers)})
|
|
except ImportError as e:
|
|
raise MlflowException(str(e), error_code=INVALID_PARAMETER_VALUE)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _list_supported_models():
|
|
try:
|
|
provider_filter = request.args.get("provider")
|
|
models = get_models(provider=provider_filter)
|
|
return jsonify({"models": models})
|
|
except ImportError as e:
|
|
raise MlflowException(str(e), error_code=INVALID_PARAMETER_VALUE)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_provider_config():
|
|
try:
|
|
provider = request.args.get("provider")
|
|
config = get_provider_config_response(provider)
|
|
return jsonify(config)
|
|
except (ImportError, ValueError) as e:
|
|
raise MlflowException(str(e), error_code=INVALID_PARAMETER_VALUE)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_secrets_config():
|
|
using_default_passphrase = not os.environ.get(CRYPTO_KEK_PASSPHRASE_ENV_VAR)
|
|
return jsonify({
|
|
"secrets_available": True,
|
|
"using_default_passphrase": using_default_passphrase,
|
|
})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _invoke_scorer_handler():
|
|
"""
|
|
Invoke a scorer on traces asynchronously.
|
|
|
|
This is a UI-only AJAX endpoint for invoking scorers from the frontend.
|
|
"""
|
|
_validate_content_type(request, ["application/json"])
|
|
|
|
args = request.json
|
|
experiment_id = args.get("experiment_id")
|
|
serialized_scorer = args.get("serialized_scorer")
|
|
trace_ids = args.get("trace_ids", [])
|
|
log_assessments = args.get("log_assessments", False)
|
|
|
|
if not experiment_id:
|
|
raise MlflowException(
|
|
"Missing required parameter: experiment_id",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
if not serialized_scorer:
|
|
raise MlflowException(
|
|
"Missing required parameter: serialized_scorer",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
if not trace_ids:
|
|
raise MlflowException(
|
|
"Please select at least one trace to evaluate.",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
from mlflow.genai.scorers.base import Scorer
|
|
from mlflow.genai.scorers.job import get_trace_batches_for_scorer, invoke_scorer_job
|
|
from mlflow.server.jobs import submit_job
|
|
|
|
scorer = Scorer.model_validate_json(serialized_scorer)
|
|
|
|
tracking_store = _get_tracking_store()
|
|
batches = get_trace_batches_for_scorer(trace_ids, scorer, tracking_store)
|
|
|
|
# Extract the authenticated username so that job subprocesses can make
|
|
# gateway requests authorised as the original user (not the admin).
|
|
username = request.authorization.username if request.authorization else None
|
|
|
|
jobs = []
|
|
for batch_trace_ids in batches:
|
|
job = submit_job(
|
|
function=invoke_scorer_job,
|
|
params={
|
|
"experiment_id": experiment_id,
|
|
"serialized_scorer": serialized_scorer,
|
|
"trace_ids": batch_trace_ids,
|
|
"log_assessments": log_assessments,
|
|
"username": username,
|
|
},
|
|
)
|
|
jobs.append({"job_id": job.job_id, "trace_ids": batch_trace_ids})
|
|
|
|
return jsonify({"jobs": jobs})
|
|
|
|
|
|
def _get_rest_path(base_path, version=2):
|
|
return _add_static_prefix(f"/api/{version}.0{base_path}")
|
|
|
|
|
|
def _get_ajax_path(base_path, version=2):
|
|
return _add_static_prefix(f"/ajax-api/{version}.0{base_path}")
|
|
|
|
|
|
def _add_static_prefix(route: str) -> str:
|
|
if prefix := os.environ.get(STATIC_PREFIX_ENV_VAR):
|
|
return prefix.rstrip("/") + route
|
|
return route
|
|
|
|
|
|
def _get_paths(base_path, version=2):
|
|
"""
|
|
A service endpoints base path is typically something like /mlflow/experiment.
|
|
We should register paths like /api/2.0/mlflow/experiment and
|
|
/ajax-api/2.0/mlflow/experiment in the Flask router.
|
|
"""
|
|
base_path = _convert_path_parameter_to_flask_format(base_path)
|
|
return [_get_rest_path(base_path, version), _get_ajax_path(base_path, version)]
|
|
|
|
|
|
def _convert_path_parameter_to_flask_format(path):
|
|
"""
|
|
Converts path parameter format to Flask compatible format.
|
|
|
|
Some protobuf endpoint paths contain parameters like /mlflow/trace/{request_id}.
|
|
This can be interpreted correctly by gRPC framework like Armeria, but Flask does
|
|
not understand it. Instead, we need to specify it with a different format,
|
|
like /mlflow/trace/<request_id>.
|
|
"""
|
|
# Handle simple parameters like {trace_id}
|
|
path = re.sub(r"{(\w+)}", r"<\1>", path)
|
|
|
|
# Handle Databricks-specific syntax like {assessment.trace_id} -> <trace_id>
|
|
# This is needed because Databricks can extract trace_id from request body,
|
|
# but Flask needs it in the URL path
|
|
return re.sub(r"{assessment\.trace_id}", r"<trace_id>", path)
|
|
|
|
|
|
def get_handler(request_class):
|
|
"""
|
|
Args:
|
|
request_class: The type of protobuf message
|
|
"""
|
|
return HANDLERS.get(request_class, _not_implemented)
|
|
|
|
|
|
def get_service_endpoints(service, get_handler):
|
|
ret = []
|
|
for service_method in service.DESCRIPTOR.methods:
|
|
endpoints = service_method.GetOptions().Extensions[databricks_pb2.rpc].endpoints
|
|
for endpoint in endpoints:
|
|
for http_path in _get_paths(endpoint.path, version=endpoint.since.major):
|
|
handler = get_handler(service().GetRequestClass(service_method))
|
|
ret.append((http_path, handler, [endpoint.method]))
|
|
return ret
|
|
|
|
|
|
def get_endpoints(get_handler=get_handler):
|
|
"""
|
|
Returns:
|
|
List of tuples (path, handler, methods)
|
|
"""
|
|
return (
|
|
get_service_endpoints(MlflowService, get_handler)
|
|
+ get_internal_online_scoring_endpoints()
|
|
+ get_service_endpoints(ModelRegistryService, get_handler)
|
|
+ get_service_endpoints(MlflowArtifactsService, get_handler)
|
|
+ get_service_endpoints(WebhookService, get_handler)
|
|
+ [(_add_static_prefix("/graphql"), _graphql, ["GET", "POST"])]
|
|
# NB: Use _get_paths() so that the endpoint is reachable at both
|
|
# <static-prefix>/api/3.0/mlflow/server-info (for the Python client)
|
|
# and <static-prefix>/ajax-api/3.0/mlflow/server-info (for the frontend).
|
|
+ [
|
|
(_path, _get_server_info, ["GET"])
|
|
for _path in _get_paths("/mlflow/server-info", version=3)
|
|
]
|
|
+ get_gateway_endpoints()
|
|
+ get_demo_endpoints()
|
|
+ get_issues_detection_endpoints()
|
|
+ get_genai_evaluate_endpoints()
|
|
+ get_job_endpoints()
|
|
)
|
|
|
|
|
|
def get_gateway_endpoints():
|
|
"""Returns endpoint tuples for gateway provider/model discovery APIs and scorer invocation."""
|
|
return [
|
|
(
|
|
_get_ajax_path("/mlflow/gateway/supported-providers", version=3),
|
|
_list_supported_providers,
|
|
["GET"],
|
|
),
|
|
(
|
|
_get_ajax_path("/mlflow/gateway/supported-models", version=3),
|
|
_list_supported_models,
|
|
["GET"],
|
|
),
|
|
(
|
|
_get_ajax_path("/mlflow/gateway/provider-config", version=3),
|
|
_get_provider_config,
|
|
["GET"],
|
|
),
|
|
(
|
|
_get_ajax_path("/mlflow/gateway/secrets/config", version=3),
|
|
_get_secrets_config,
|
|
["GET"],
|
|
),
|
|
(
|
|
_get_ajax_path("/mlflow/scorer/invoke", version=3),
|
|
_invoke_scorer_handler,
|
|
["POST"],
|
|
),
|
|
]
|
|
|
|
|
|
def get_issues_detection_endpoints():
|
|
return [
|
|
(
|
|
_get_ajax_path("/mlflow/issues/invoke", version=3),
|
|
_invoke_issue_detection_handler,
|
|
["POST"],
|
|
),
|
|
]
|
|
|
|
|
|
def get_genai_evaluate_endpoints():
|
|
return [
|
|
(
|
|
_get_ajax_path("/mlflow/genai/evaluate/invoke", version=3),
|
|
_invoke_genai_evaluate_handler,
|
|
["POST"],
|
|
),
|
|
]
|
|
|
|
|
|
def get_job_endpoints():
|
|
return [
|
|
(
|
|
_get_ajax_path("/mlflow/jobs/cancel/<job_id>", version=3),
|
|
_cancel_job,
|
|
["PATCH"],
|
|
),
|
|
(
|
|
_get_ajax_path("/mlflow/jobs/<job_id>", version=3),
|
|
_get_job,
|
|
["GET"],
|
|
),
|
|
]
|
|
|
|
|
|
# Demo APIs
|
|
|
|
# Serialize demo generation so concurrent requests (e.g. FastAPI running Flask
|
|
# handlers in a thread pool) cannot race on the process-wide MLFLOW_WORKSPACE
|
|
# env var that WorkspaceContext temporarily sets during generate_all_demos.
|
|
_demo_generate_lock = threading.Lock()
|
|
|
|
|
|
def get_demo_endpoints():
|
|
"""Returns endpoint tuples for demo data generation and deletion APIs."""
|
|
return [
|
|
(
|
|
_get_ajax_path("/mlflow/demo/generate", version=3),
|
|
_generate_demo,
|
|
["POST"],
|
|
),
|
|
(
|
|
_get_ajax_path("/mlflow/demo/delete", version=3),
|
|
_delete_demo,
|
|
["POST"],
|
|
),
|
|
]
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _generate_demo():
|
|
"""Generate demo data for registered demo generators.
|
|
|
|
Accepts an optional JSON body with a ``features`` list to generate only specific
|
|
features (e.g. ``{"features": ["traces", "prompts"]}``). When omitted, all features
|
|
are generated.
|
|
"""
|
|
from mlflow.demo import generate_all_demos
|
|
from mlflow.demo.base import DEMO_EXPERIMENT_NAME
|
|
from mlflow.demo.registry import demo_registry
|
|
|
|
request_json = request.get_json(silent=True) or {}
|
|
features = request_json.get("features")
|
|
|
|
store = _get_tracking_store()
|
|
experiment = store.get_experiment_by_name(DEMO_EXPERIMENT_NAME)
|
|
|
|
generator_names = demo_registry.list_generators()
|
|
if features is not None:
|
|
generator_names = [n for n in generator_names if n in features]
|
|
|
|
all_exist = False
|
|
if experiment and experiment.lifecycle_stage == "active":
|
|
all_exist = all(demo_registry.get(name)().is_generated() for name in generator_names)
|
|
|
|
if experiment and all_exist:
|
|
return jsonify({
|
|
"status": "exists",
|
|
"experiment_id": experiment.experiment_id,
|
|
"features_generated": [],
|
|
"navigation_url": f"/experiments/{experiment.experiment_id}",
|
|
})
|
|
|
|
with _demo_generate_lock:
|
|
results = generate_all_demos(features=features)
|
|
|
|
experiment = store.get_experiment_by_name(DEMO_EXPERIMENT_NAME)
|
|
experiment_id = experiment.experiment_id if experiment else None
|
|
navigation_url = f"/experiments/{experiment_id}" if experiment_id else "/experiments"
|
|
|
|
return jsonify({
|
|
"status": "created",
|
|
"experiment_id": experiment_id,
|
|
"features_generated": [r.feature for r in results],
|
|
"navigation_url": navigation_url,
|
|
})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_demo():
|
|
"""Delete demo data for all registered demo generators.
|
|
|
|
Performs a full hard delete of the demo experiment and all associated data,
|
|
equivalent to what `mlflow gc` would do. This ensures the demo data is
|
|
completely removed rather than just soft-deleted.
|
|
"""
|
|
from mlflow.demo.base import DEMO_EXPERIMENT_NAME
|
|
from mlflow.demo.registry import demo_registry
|
|
|
|
deleted_features = []
|
|
for name in demo_registry.list_generators():
|
|
generator = demo_registry.get(name)()
|
|
if generator._data_exists():
|
|
generator.delete_demo()
|
|
deleted_features.append(name)
|
|
|
|
store = _get_tracking_store()
|
|
experiment = store.get_experiment_by_name(DEMO_EXPERIMENT_NAME)
|
|
if experiment and experiment.lifecycle_stage == "active":
|
|
store.delete_experiment(experiment.experiment_id)
|
|
|
|
return jsonify({
|
|
"status": "deleted",
|
|
"features_deleted": deleted_features,
|
|
})
|
|
|
|
|
|
def get_internal_online_scoring_endpoints():
|
|
"""Returns endpoint definitions for internal (non public) online scoring APIs."""
|
|
return [
|
|
(
|
|
_get_ajax_path("/mlflow/scorers/online-configs", version=3),
|
|
_get_online_scoring_configs,
|
|
["GET"],
|
|
),
|
|
(
|
|
_get_rest_path("/mlflow/scorers/online-configs", version=3),
|
|
_get_online_scoring_configs,
|
|
["GET"],
|
|
),
|
|
(
|
|
_get_ajax_path("/mlflow/scorers/online-config", version=3),
|
|
_upsert_online_scoring_config,
|
|
["PUT"],
|
|
),
|
|
(
|
|
_get_rest_path("/mlflow/scorers/online-config", version=3),
|
|
_upsert_online_scoring_config,
|
|
["PUT"],
|
|
),
|
|
]
|
|
|
|
|
|
# Evaluation Dataset APIs
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _create_dataset_handler():
|
|
request_message = _get_request_message(
|
|
CreateDataset(),
|
|
schema={
|
|
"name": [_assert_required, _assert_string],
|
|
"experiment_ids": [_assert_array],
|
|
"tags": [_assert_string],
|
|
},
|
|
)
|
|
|
|
tags = None
|
|
if hasattr(request_message, "tags") and request_message.tags:
|
|
tags = json.loads(request_message.tags)
|
|
|
|
dataset = _get_tracking_store().create_dataset(
|
|
name=request_message.name,
|
|
experiment_ids=list(request_message.experiment_ids)
|
|
if request_message.experiment_ids
|
|
else None,
|
|
tags=tags,
|
|
)
|
|
|
|
response_message = CreateDataset.Response()
|
|
response_message.dataset.CopyFrom(dataset.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_dataset_handler(dataset_id):
|
|
dataset = _get_tracking_store().get_dataset(dataset_id)
|
|
|
|
response_message = GetDataset.Response()
|
|
response_message.dataset.CopyFrom(dataset.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_dataset_handler(dataset_id):
|
|
_get_tracking_store().delete_dataset(dataset_id)
|
|
|
|
response_message = DeleteDataset.Response()
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _search_evaluation_datasets_handler():
|
|
request_message = _get_request_message(
|
|
SearchEvaluationDatasets(),
|
|
schema={
|
|
"experiment_ids": [_assert_array],
|
|
"filter_string": [_assert_string],
|
|
"max_results": [_assert_intlike],
|
|
"order_by": [_assert_array],
|
|
"page_token": [_assert_string],
|
|
},
|
|
)
|
|
|
|
datasets = _get_tracking_store().search_datasets(
|
|
experiment_ids=list(request_message.experiment_ids)
|
|
if request_message.experiment_ids
|
|
else None,
|
|
filter_string=request_message.filter_string or None,
|
|
max_results=request_message.max_results or None,
|
|
order_by=list(request_message.order_by) if request_message.order_by else None,
|
|
page_token=request_message.page_token or None,
|
|
)
|
|
|
|
response_message = SearchEvaluationDatasets.Response()
|
|
response_message.datasets.extend([d.to_proto() for d in datasets])
|
|
if datasets.token:
|
|
response_message.next_page_token = datasets.token
|
|
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _set_dataset_tags_handler(dataset_id):
|
|
request_message = _get_request_message(
|
|
SetDatasetTags(),
|
|
schema={
|
|
"tags": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
|
|
tags = json.loads(request_message.tags)
|
|
|
|
_get_tracking_store().set_dataset_tags(
|
|
dataset_id=dataset_id,
|
|
tags=tags,
|
|
)
|
|
|
|
response_message = SetDatasetTags.Response()
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_dataset_tag_handler(dataset_id, key):
|
|
_get_tracking_store().delete_dataset_tag(
|
|
dataset_id=dataset_id,
|
|
key=key,
|
|
)
|
|
|
|
response_message = DeleteDatasetTag.Response()
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _upsert_dataset_records_handler(dataset_id):
|
|
request_message = _get_request_message(
|
|
UpsertDatasetRecords(),
|
|
schema={
|
|
"records": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
|
|
records = json.loads(request_message.records)
|
|
|
|
result = _get_tracking_store().upsert_dataset_records(
|
|
dataset_id=dataset_id,
|
|
records=records,
|
|
)
|
|
|
|
response_message = UpsertDatasetRecords.Response()
|
|
response_message.inserted_count = result["inserted"]
|
|
response_message.updated_count = result["updated"]
|
|
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
def _get_dataset_experiment_ids_handler(dataset_id):
|
|
"""
|
|
Get experiment IDs associated with an evaluation dataset.
|
|
"""
|
|
experiment_ids = _get_tracking_store().get_dataset_experiment_ids(dataset_id=dataset_id)
|
|
|
|
response_message = GetDatasetExperimentIds.Response()
|
|
response_message.experiment_ids.extend(experiment_ids)
|
|
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _add_dataset_to_experiments_handler(dataset_id):
|
|
request_message = _get_request_message(
|
|
AddDatasetToExperiments(),
|
|
schema={
|
|
"experiment_ids": [_assert_array],
|
|
},
|
|
)
|
|
|
|
dataset = _get_tracking_store().add_dataset_to_experiments(
|
|
dataset_id=dataset_id,
|
|
experiment_ids=request_message.experiment_ids,
|
|
)
|
|
|
|
response_message = AddDatasetToExperiments.Response()
|
|
response_message.dataset.CopyFrom(dataset.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _remove_dataset_from_experiments_handler(dataset_id):
|
|
request_message = _get_request_message(
|
|
RemoveDatasetFromExperiments(),
|
|
schema={
|
|
"experiment_ids": [_assert_array],
|
|
},
|
|
)
|
|
|
|
dataset = _get_tracking_store().remove_dataset_from_experiments(
|
|
dataset_id=dataset_id,
|
|
experiment_ids=request_message.experiment_ids,
|
|
)
|
|
|
|
response_message = RemoveDatasetFromExperiments.Response()
|
|
response_message.dataset.CopyFrom(dataset.to_proto())
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
def _get_dataset_records_handler(dataset_id):
|
|
request_message = _get_request_message(
|
|
GetDatasetRecords(),
|
|
schema={
|
|
"max_results": [_assert_intlike],
|
|
"page_token": [_assert_string],
|
|
},
|
|
)
|
|
|
|
max_results = request_message.max_results or 1000
|
|
page_token = request_message.page_token or None
|
|
|
|
# Use the pagination-aware method
|
|
records, next_page_token = _get_tracking_store()._load_dataset_records(
|
|
dataset_id, max_results=max_results, page_token=page_token
|
|
)
|
|
|
|
response_message = GetDatasetRecords.Response()
|
|
|
|
records_dicts = [record.to_dict() for record in records]
|
|
response_message.records = json.dumps(records_dicts)
|
|
|
|
if next_page_token:
|
|
response_message.next_page_token = next_page_token
|
|
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_dataset_records_handler(dataset_id):
|
|
request_message = _get_request_message(
|
|
DeleteDatasetRecords(),
|
|
schema={
|
|
"dataset_record_ids": [_assert_array],
|
|
},
|
|
)
|
|
|
|
deleted_count = _get_tracking_store().delete_dataset_records(
|
|
dataset_id=dataset_id,
|
|
dataset_record_ids=list(request_message.dataset_record_ids),
|
|
)
|
|
|
|
response_message = DeleteDatasetRecords.Response()
|
|
response_message.deleted_count = deleted_count
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
# Cache for telemetry config with 3 hour TTL
|
|
_telemetry_config_cache = TTLCache(maxsize=1, ttl=10800)
|
|
|
|
|
|
def _get_or_fetch_ui_telemetry_config():
|
|
if (config := _telemetry_config_cache.get("config")) is None:
|
|
config = fetch_ui_telemetry_config()
|
|
_telemetry_config_cache["config"] = config
|
|
return config
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def get_ui_telemetry_handler():
|
|
"""
|
|
GET handler for /telemetry endpoint.
|
|
Returns the telemetry client configuration by fetching it directly.
|
|
"""
|
|
if is_telemetry_disabled():
|
|
return jsonify(FALLBACK_UI_CONFIG)
|
|
|
|
config = _get_or_fetch_ui_telemetry_config()
|
|
|
|
# UI telemetry should be also disabled if overall telemetry is disabled
|
|
disable_ui_telemetry = config.get("disable_ui_telemetry", True) or config.get(
|
|
"disable_telemetry", True
|
|
)
|
|
response = {
|
|
"disable_ui_telemetry": disable_ui_telemetry,
|
|
"disable_ui_events": config.get("disable_ui_events", []),
|
|
"ui_rollout_percentage": config.get("ui_rollout_percentage", 0),
|
|
}
|
|
return jsonify(response)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def post_ui_telemetry_handler():
|
|
"""
|
|
POST handler for /telemetry endpoint.
|
|
Accepts telemetry records and adds them to the telemetry client.
|
|
"""
|
|
try:
|
|
if is_telemetry_disabled():
|
|
return jsonify({"status": "disabled"})
|
|
|
|
data = request.json.get("records", [])
|
|
|
|
if not data:
|
|
return jsonify({"status": "success"})
|
|
|
|
if (client := get_telemetry_client()) is None:
|
|
return jsonify({"status": "disabled"})
|
|
|
|
# check cached config to see if telemetry is disabled
|
|
# if so, don't process the records. we don't rely on the
|
|
# config from the telemetry client because it is only fetched
|
|
# once, so it won't be updated unless the server is restarted.
|
|
config = _get_or_fetch_ui_telemetry_config()
|
|
|
|
# if updated telemetry config is disabled / missing, tell the UI to stop sending records
|
|
if config.get("disable_ui_telemetry", True) or config.get("disable_telemetry", True):
|
|
return jsonify({"status": "disabled"})
|
|
|
|
server_installation_id = get_or_create_installation_id()
|
|
records = [
|
|
Record(
|
|
event_name=event["event_name"],
|
|
timestamp_ns=event["timestamp_ns"],
|
|
params=event["params"],
|
|
status=Status.SUCCESS,
|
|
installation_id=event["installation_id"],
|
|
session_id=event["session_id"],
|
|
server_installation_id=server_installation_id,
|
|
duration_ms=0,
|
|
)
|
|
for event in data
|
|
]
|
|
|
|
client.add_records(records)
|
|
|
|
return jsonify({"status": "success"})
|
|
except Exception as e:
|
|
_logger.debug(f"Failed to process UI telemetry records: {e}")
|
|
# if we run into unexpected errors, likely something is wrong
|
|
# with the data format. if we return success, the UI will continue
|
|
# to send records. if we return an error, the UI will retry sending
|
|
# records. the safest thing to do is to tell the UI to stop sending
|
|
return jsonify({"status": "disabled"})
|
|
|
|
|
|
def _parse_prompt_uri(prompt_uri: str) -> tuple[str, str]:
|
|
"""
|
|
Parse a prompt URI to extract the prompt name and version.
|
|
|
|
Args:
|
|
prompt_uri: Prompt URI in the format "prompts:/prompt_name/version"
|
|
|
|
Returns:
|
|
A tuple of (prompt_name, version). Returns empty strings if parsing fails.
|
|
"""
|
|
try:
|
|
# Format: "prompts:/prompt_name/version"
|
|
if prompt_uri.startswith("prompts:/"):
|
|
parts = prompt_uri.replace("prompts:/", "").split("/")
|
|
if len(parts) >= 2:
|
|
return parts[0], parts[1]
|
|
except Exception:
|
|
pass
|
|
return "", ""
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _create_prompt_optimization_job():
|
|
# These imports must be local to avoid circular import with mlflow.server.jobs
|
|
from mlflow.genai.datasets import get_dataset as get_genai_dataset
|
|
from mlflow.genai.optimize.job import OptimizerType, optimize_prompts_job
|
|
from mlflow.server.jobs import submit_job
|
|
|
|
request_message = _get_request_message(
|
|
CreatePromptOptimizationJob(),
|
|
schema={
|
|
"experiment_id": [_assert_string],
|
|
"source_prompt_uri": [_assert_string, _assert_required],
|
|
"config": [_assert_required],
|
|
"tags": [_assert_array],
|
|
},
|
|
)
|
|
|
|
prompt_uri = request_message.source_prompt_uri or ""
|
|
if not prompt_uri:
|
|
raise MlflowException(
|
|
"source_prompt_uri is required for optimization job",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
config = request_message.config
|
|
dataset_id = config.dataset_id or ""
|
|
|
|
scorers = list(config.scorers) if config.scorers else []
|
|
|
|
optimizer_type = OptimizerType.from_proto(config.optimizer_type)
|
|
|
|
experiment_id = (request_message.experiment_id or "").strip()
|
|
if not experiment_id:
|
|
raise MlflowException(
|
|
"experiment_id is required for optimization job",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
# Parse optimizer_config_json to dict for the job function
|
|
# Validate before creating run to avoid creating unused runs on validation failure
|
|
optimizer_config = None
|
|
if config.optimizer_config_json:
|
|
try:
|
|
optimizer_config = json.loads(config.optimizer_config_json)
|
|
except json.JSONDecodeError as e:
|
|
raise MlflowException(
|
|
f"Invalid JSON in optimizer_config_json: {e}",
|
|
error_code=INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
# Create MLflow run upfront so run_id is immediately available
|
|
# The job will resume this run when it starts executing
|
|
tracking_store = _get_tracking_store()
|
|
start_time = int(time.time() * 1000)
|
|
|
|
# Parse prompt name and version from URI for more descriptive run name
|
|
prompt_name, prompt_version = _parse_prompt_uri(prompt_uri)
|
|
run_name = f"optimize_prompt_{optimizer_type}_{prompt_name}_{prompt_version}_{start_time}"
|
|
|
|
run = tracking_store.create_run(
|
|
experiment_id=experiment_id,
|
|
user_id=_get_user(),
|
|
start_time=start_time,
|
|
tags=[],
|
|
run_name=run_name,
|
|
)
|
|
run_id = run.info.run_id
|
|
|
|
# Log optimization config as run parameters
|
|
params_to_log = [
|
|
Param("source_prompt_uri", prompt_uri),
|
|
Param("optimizer_type", optimizer_type),
|
|
Param("dataset_id", dataset_id),
|
|
Param("scorer_names", json.dumps(scorers)),
|
|
]
|
|
if config.optimizer_config_json:
|
|
params_to_log.append(Param("optimizer_config_json", config.optimizer_config_json))
|
|
tracking_store.log_batch(run_id=run_id, metrics=[], params=params_to_log, tags=[])
|
|
|
|
# Link the evaluation dataset to the run for lineage tracking (if dataset_id is provided)
|
|
if dataset_id:
|
|
dataset = get_genai_dataset(dataset_id=dataset_id)
|
|
dataset_input = DatasetInput(
|
|
dataset=dataset._to_mlflow_entity(),
|
|
tags=[InputTag(key="mlflow.data.context", value="optimization")],
|
|
)
|
|
tracking_store.log_inputs(run_id=run_id, datasets=[dataset_input])
|
|
|
|
params = {
|
|
"run_id": run_id,
|
|
"experiment_id": experiment_id,
|
|
"prompt_uri": prompt_uri,
|
|
"dataset_id": dataset_id,
|
|
"optimizer_type": optimizer_type,
|
|
"optimizer_config": optimizer_config,
|
|
"scorer_names": scorers,
|
|
}
|
|
|
|
job_entity = submit_job(optimize_prompts_job, params)
|
|
|
|
response_message = CreatePromptOptimizationJob.Response()
|
|
optimization_job = PromptOptimizationJobProto()
|
|
optimization_job.job_id = job_entity.job_id
|
|
optimization_job.run_id = run_id
|
|
optimization_job.state.status = JobStatus.JOB_STATUS_PENDING
|
|
optimization_job.creation_timestamp_ms = job_entity.creation_time
|
|
optimization_job.experiment_id = experiment_id
|
|
optimization_job.config.CopyFrom(config)
|
|
optimization_job.source_prompt_uri = prompt_uri
|
|
|
|
for tag in request_message.tags:
|
|
job_tag = optimization_job.tags.add()
|
|
job_tag.key = tag.key
|
|
job_tag.value = tag.value
|
|
|
|
response_message.job.CopyFrom(optimization_job)
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
def _build_prompt_optimization_job_from_entity(job_entity):
|
|
from mlflow.genai.optimize.job import OptimizerType
|
|
|
|
optimization_job = PromptOptimizationJobProto()
|
|
optimization_job.job_id = job_entity.job_id
|
|
optimization_job.state.status = job_entity.status.to_proto()
|
|
optimization_job.creation_timestamp_ms = job_entity.creation_time
|
|
|
|
params = json.loads(job_entity.params)
|
|
if "experiment_id" in params:
|
|
optimization_job.experiment_id = params["experiment_id"]
|
|
if "prompt_uri" in params:
|
|
optimization_job.source_prompt_uri = params["prompt_uri"]
|
|
|
|
if run_id := params.get("run_id"):
|
|
optimization_job.run_id = run_id
|
|
|
|
# Populate config from job params
|
|
config = optimization_job.config
|
|
if "optimizer_type" in params:
|
|
try:
|
|
optimizer_type = OptimizerType(params["optimizer_type"])
|
|
config.optimizer_type = optimizer_type.to_proto()
|
|
except (ValueError, KeyError):
|
|
pass
|
|
if params.get("dataset_id"):
|
|
config.dataset_id = params["dataset_id"]
|
|
if "scorer_names" in params:
|
|
try:
|
|
scorer_names = params["scorer_names"]
|
|
if isinstance(scorer_names, str):
|
|
scorer_names = json.loads(scorer_names)
|
|
if isinstance(scorer_names, list):
|
|
config.scorers.extend(scorer_names)
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
if params.get("optimizer_config"):
|
|
optimizer_config = params["optimizer_config"]
|
|
if isinstance(optimizer_config, dict):
|
|
config.optimizer_config_json = json.dumps(optimizer_config)
|
|
elif isinstance(optimizer_config, str):
|
|
config.optimizer_config_json = optimizer_config
|
|
|
|
# Get optimized_prompt_uri from job result (only available when job succeeds)
|
|
if job_entity.status.name == "SUCCEEDED" and job_entity.parsed_result:
|
|
result = job_entity.parsed_result
|
|
if isinstance(result, dict) and result.get("optimized_prompt_uri"):
|
|
optimization_job.optimized_prompt_uri = result["optimized_prompt_uri"]
|
|
|
|
# If job failed, add error message to state
|
|
if job_entity.status.name == "FAILED" and job_entity.parsed_result:
|
|
optimization_job.state.error_message = str(job_entity.parsed_result)
|
|
|
|
return optimization_job
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _get_prompt_optimization_job(job_id):
|
|
from mlflow.server.jobs import get_job
|
|
|
|
job_entity = get_job(job_id)
|
|
optimization_job = _build_prompt_optimization_job_from_entity(job_entity)
|
|
|
|
# Fetch MLflow run to get evaluation scores from metrics
|
|
try:
|
|
mlflow_run = _get_tracking_store().get_run(optimization_job.run_id)
|
|
run_metrics = mlflow_run.data.metrics
|
|
|
|
# Populate evaluation scores from run metrics
|
|
# Aggregated scores are logged as "initial_eval_score" and "final_eval_score"
|
|
# Per-scorer scores are logged as "initial_eval_score.<scorer_name>" and
|
|
# "final_eval_score.<scorer_name>"
|
|
total_metric_calls = None
|
|
for metric_name, metric_value in run_metrics.items():
|
|
match metric_name.split(".", 1):
|
|
case ["initial_eval_score"]:
|
|
optimization_job.initial_eval_scores["aggregate"] = metric_value
|
|
case ["final_eval_score"]:
|
|
optimization_job.final_eval_scores["aggregate"] = metric_value
|
|
case ["initial_eval_score", scorer_name]:
|
|
optimization_job.initial_eval_scores[scorer_name] = metric_value
|
|
case ["final_eval_score", scorer_name]:
|
|
optimization_job.final_eval_scores[scorer_name] = metric_value
|
|
case ["total_metric_calls"]:
|
|
total_metric_calls = metric_value
|
|
|
|
if total_metric_calls is not None:
|
|
params = json.loads(job_entity.params)
|
|
optimizer_config = params.get("optimizer_config", {})
|
|
if max_metric_calls := optimizer_config.get("max_metric_calls"):
|
|
progress = round(min(total_metric_calls / max_metric_calls, 1.0), 2)
|
|
optimization_job.state.metadata["progress"] = str(progress)
|
|
|
|
except Exception as e:
|
|
_logger.debug("Failed to fetch run details for optimization job %s: %s", job_id, e)
|
|
|
|
response_message = GetPromptOptimizationJob.Response()
|
|
response_message.job.CopyFrom(optimization_job)
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _search_prompt_optimization_jobs():
|
|
request_message = _get_request_message(
|
|
SearchPromptOptimizationJobs(),
|
|
schema={
|
|
"experiment_id": [_assert_required, _assert_string],
|
|
},
|
|
)
|
|
|
|
job_store = _get_job_store()
|
|
|
|
# Search for optimize_prompts jobs in the specified experiment
|
|
jobs = job_store.list_jobs(
|
|
job_name="optimize_prompts",
|
|
params={"experiment_id": request_message.experiment_id},
|
|
)
|
|
|
|
response_message = SearchPromptOptimizationJobs.Response()
|
|
|
|
for job_entity in jobs:
|
|
optimization_job = _build_prompt_optimization_job_from_entity(job_entity)
|
|
response_message.jobs.append(optimization_job)
|
|
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _cancel_prompt_optimization_job(job_id):
|
|
# This import must be local to avoid circular import with mlflow.server.jobs
|
|
from mlflow.server.jobs import cancel_job
|
|
|
|
job_entity = cancel_job(job_id)
|
|
optimization_job = _build_prompt_optimization_job_from_entity(job_entity)
|
|
# Override status to CANCELED since cancel_job may not update the entity status immediately
|
|
optimization_job.state.status = JobStatus.JOB_STATUS_CANCELED
|
|
|
|
# Terminate the underlying MLflow run if it exists
|
|
if optimization_job.run_id:
|
|
try:
|
|
_get_tracking_store().update_run_info(
|
|
run_id=optimization_job.run_id,
|
|
run_status=RunStatus.KILLED,
|
|
end_time=get_current_time_millis(),
|
|
run_name=None,
|
|
)
|
|
except Exception:
|
|
# If the run doesn't exist or is already terminated, log warning and continue
|
|
_logger.warning(
|
|
"Failed to terminate MLflow run '%s' when canceling job '%s'",
|
|
optimization_job.run_id,
|
|
job_id,
|
|
)
|
|
|
|
response_message = CancelPromptOptimizationJob.Response()
|
|
response_message.job.CopyFrom(optimization_job)
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
@_disable_if_artifacts_only
|
|
def _delete_prompt_optimization_job(job_id):
|
|
job_store = _get_job_store()
|
|
job_entity = job_store.get_job(job_id)
|
|
optimization_job = _build_prompt_optimization_job_from_entity(job_entity)
|
|
run_id = optimization_job.run_id
|
|
|
|
job_store.delete_jobs(job_ids=[job_id])
|
|
|
|
# Delete the associated MLflow run if it exists.
|
|
# Check if run exists before attempting deletion - user may have
|
|
# deleted it manually before the job deletion request.
|
|
if run_id:
|
|
try:
|
|
_get_tracking_store().get_run(run_id)
|
|
_get_tracking_store().delete_run(run_id)
|
|
except MlflowException:
|
|
pass
|
|
|
|
response_message = DeletePromptOptimizationJob.Response()
|
|
return _wrap_response(response_message)
|
|
|
|
|
|
HANDLERS = {
|
|
# Tracking Server APIs
|
|
CreateExperiment: _create_experiment,
|
|
GetExperiment: _get_experiment,
|
|
GetExperimentByName: _get_experiment_by_name,
|
|
DeleteExperiment: _delete_experiment,
|
|
RestoreExperiment: _restore_experiment,
|
|
UpdateExperiment: _update_experiment,
|
|
CreateRun: _create_run,
|
|
UpdateRun: _update_run,
|
|
DeleteRun: _delete_run,
|
|
RestoreRun: _restore_run,
|
|
LogParam: _log_param,
|
|
LogMetric: _log_metric,
|
|
SetExperimentTag: _set_experiment_tag,
|
|
DeleteExperimentTag: _delete_experiment_tag,
|
|
SetTag: _set_tag,
|
|
DeleteTag: _delete_tag,
|
|
LogBatch: _log_batch,
|
|
LogModel: _log_model,
|
|
GetRun: _get_run,
|
|
SearchRuns: _search_runs,
|
|
ListArtifacts: _list_artifacts,
|
|
CreatePresignedUploadUrl: _create_presigned_upload_url,
|
|
GetMetricHistory: _get_metric_history,
|
|
GetMetricHistoryBulkInterval: get_metric_history_bulk_interval_handler,
|
|
SearchExperiments: _search_experiments,
|
|
LogInputs: _log_inputs,
|
|
LogOutputs: _log_outputs,
|
|
# Evaluation Dataset APIs
|
|
CreateDataset: _create_dataset_handler,
|
|
GetDataset: _get_dataset_handler,
|
|
DeleteDataset: _delete_dataset_handler,
|
|
SearchEvaluationDatasets: _search_evaluation_datasets_handler,
|
|
SetDatasetTags: _set_dataset_tags_handler,
|
|
DeleteDatasetTag: _delete_dataset_tag_handler,
|
|
UpsertDatasetRecords: _upsert_dataset_records_handler,
|
|
GetDatasetExperimentIds: _get_dataset_experiment_ids_handler,
|
|
GetDatasetRecords: _get_dataset_records_handler,
|
|
DeleteDatasetRecords: _delete_dataset_records_handler,
|
|
AddDatasetToExperiments: _add_dataset_to_experiments_handler,
|
|
RemoveDatasetFromExperiments: _remove_dataset_from_experiments_handler,
|
|
# Model Registry APIs
|
|
CreateRegisteredModel: _create_registered_model,
|
|
GetRegisteredModel: _get_registered_model,
|
|
DeleteRegisteredModel: _delete_registered_model,
|
|
UpdateRegisteredModel: _update_registered_model,
|
|
RenameRegisteredModel: _rename_registered_model,
|
|
SearchRegisteredModels: _search_registered_models,
|
|
GetLatestVersions: _get_latest_versions,
|
|
CreateModelVersion: _create_model_version,
|
|
GetModelVersion: _get_model_version,
|
|
DeleteModelVersion: _delete_model_version,
|
|
UpdateModelVersion: _update_model_version,
|
|
TransitionModelVersionStage: _transition_stage,
|
|
GetModelVersionDownloadUri: _get_model_version_download_uri,
|
|
SearchModelVersions: _search_model_versions,
|
|
SetRegisteredModelTag: _set_registered_model_tag,
|
|
DeleteRegisteredModelTag: _delete_registered_model_tag,
|
|
SetModelVersionTag: _set_model_version_tag,
|
|
DeleteModelVersionTag: _delete_model_version_tag,
|
|
SetRegisteredModelAlias: _set_registered_model_alias,
|
|
DeleteRegisteredModelAlias: _delete_registered_model_alias,
|
|
GetModelVersionByAlias: _get_model_version_by_alias,
|
|
# Webhook APIs
|
|
CreateWebhook: _create_webhook,
|
|
ListWebhooks: _list_webhooks,
|
|
GetWebhook: _get_webhook,
|
|
UpdateWebhook: _update_webhook,
|
|
DeleteWebhook: _delete_webhook,
|
|
TestWebhook: _test_webhook,
|
|
# MLflow Artifacts APIs
|
|
DownloadArtifact: _download_artifact,
|
|
UploadArtifact: _upload_artifact,
|
|
ListArtifactsMlflowArtifacts: _list_artifacts_mlflow_artifacts,
|
|
DeleteArtifact: _delete_artifact_mlflow_artifacts,
|
|
CreateMultipartUpload: _create_multipart_upload_artifact,
|
|
CompleteMultipartUpload: _complete_multipart_upload_artifact,
|
|
AbortMultipartUpload: _abort_multipart_upload_artifact,
|
|
GetPresignedDownloadUrl: _get_presigned_download_url,
|
|
# MLflow Tracing APIs (V3)
|
|
StartTraceV3: _start_trace_v3,
|
|
GetTraceInfoV3: _get_trace_info_v3,
|
|
SearchTracesV3: _search_traces_v3,
|
|
DeleteTracesV3: _delete_traces,
|
|
CalculateTraceFilterCorrelation: _calculate_trace_filter_correlation,
|
|
SetTraceTagV3: _set_trace_tag_v3,
|
|
DeleteTraceTagV3: _delete_trace_tag_v3,
|
|
LinkTracesToRun: _link_traces_to_run,
|
|
LinkPromptsToTrace: _link_prompts_to_trace,
|
|
BatchGetTraces: _batch_get_traces,
|
|
BatchGetTraceInfos: _batch_get_trace_infos,
|
|
GetTrace: _get_trace,
|
|
QueryTraceMetrics: _query_trace_metrics,
|
|
# Assessment APIs
|
|
CreateAssessment: _create_assessment,
|
|
GetAssessmentRequest: _get_assessment,
|
|
UpdateAssessment: _update_assessment,
|
|
DeleteAssessment: _delete_assessment,
|
|
# Issue APIs
|
|
CreateIssue: _create_issue,
|
|
UpdateIssue: _update_issue,
|
|
GetIssue: _get_issue,
|
|
SearchIssues: _search_issues,
|
|
# Label Schema APIs
|
|
CreateLabelSchema: _create_label_schema,
|
|
GetLabelSchema: _get_label_schema,
|
|
GetLabelSchemaByName: _get_label_schema_by_name,
|
|
ListLabelSchemas: _list_label_schemas,
|
|
UpdateLabelSchema: _update_label_schema,
|
|
DeleteLabelSchema: _delete_label_schema,
|
|
CreateReviewQueue: _create_review_queue,
|
|
GetOrCreateUserQueue: _get_or_create_user_queue,
|
|
GetReviewQueue: _get_review_queue,
|
|
GetReviewQueueByName: _get_review_queue_by_name,
|
|
ListReviewQueues: _list_review_queues,
|
|
UpdateReviewQueue: _update_review_queue,
|
|
DeleteReviewQueue: _delete_review_queue,
|
|
AddItemsToReviewQueue: _add_items_to_review_queue,
|
|
RemoveItemsFromReviewQueue: _remove_items_from_review_queue,
|
|
ListReviewQueueItems: _list_review_queue_items,
|
|
SetReviewQueueItemStatus: _set_review_queue_item_status,
|
|
# Legacy MLflow Tracing V2 APIs. Kept for backward compatibility but do not use.
|
|
StartTrace: _deprecated_start_trace_v2,
|
|
EndTrace: _deprecated_end_trace_v2,
|
|
GetTraceInfo: _deprecated_get_trace_info_v2,
|
|
SearchTraces: _deprecated_search_traces_v2,
|
|
DeleteTraces: _delete_traces,
|
|
SetTraceTag: _set_trace_tag,
|
|
DeleteTraceTag: _delete_trace_tag,
|
|
# Logged Models APIs
|
|
CreateLoggedModel: _create_logged_model,
|
|
GetLoggedModel: _get_logged_model,
|
|
FinalizeLoggedModel: _finalize_logged_model,
|
|
DeleteLoggedModel: _delete_logged_model,
|
|
SetLoggedModelTags: _set_logged_model_tags,
|
|
DeleteLoggedModelTag: _delete_logged_model_tag,
|
|
SearchLoggedModels: _search_logged_models,
|
|
ListLoggedModelArtifacts: _list_logged_model_artifacts,
|
|
LogLoggedModelParamsRequest: _log_logged_model_params,
|
|
# Scorer APIs
|
|
RegisterScorer: _register_scorer,
|
|
ListScorers: _list_scorers,
|
|
ListScorerVersions: _list_scorer_versions,
|
|
GetScorer: _get_scorer,
|
|
DeleteScorer: _delete_scorer,
|
|
# Secrets APIs
|
|
CreateGatewaySecret: _create_gateway_secret,
|
|
GetGatewaySecretInfo: _get_gateway_secret_info,
|
|
UpdateGatewaySecret: _update_gateway_secret,
|
|
DeleteGatewaySecret: _delete_gateway_secret,
|
|
ListGatewaySecretInfos: _list_gateway_secrets,
|
|
# Endpoints APIs
|
|
CreateGatewayEndpoint: _create_gateway_endpoint,
|
|
GetGatewayEndpoint: _get_gateway_endpoint,
|
|
UpdateGatewayEndpoint: _update_gateway_endpoint,
|
|
DeleteGatewayEndpoint: _delete_gateway_endpoint,
|
|
ListGatewayEndpoints: _list_gateway_endpoints,
|
|
# Model Definitions APIs
|
|
CreateGatewayModelDefinition: _create_gateway_model_definition,
|
|
GetGatewayModelDefinition: _get_gateway_model_definition,
|
|
ListGatewayModelDefinitions: _list_gateway_model_definitions,
|
|
UpdateGatewayModelDefinition: _update_gateway_model_definition,
|
|
DeleteGatewayModelDefinition: _delete_gateway_model_definition,
|
|
# Endpoint Model Mappings APIs
|
|
AttachModelToGatewayEndpoint: _attach_model_to_gateway_endpoint,
|
|
DetachModelFromGatewayEndpoint: _detach_model_from_gateway_endpoint,
|
|
# Endpoint Bindings APIs
|
|
CreateGatewayEndpointBinding: _create_gateway_endpoint_binding,
|
|
DeleteGatewayEndpointBinding: _delete_gateway_endpoint_binding,
|
|
ListGatewayEndpointBindings: _list_gateway_endpoint_bindings,
|
|
# Endpoint Tags APIs
|
|
SetGatewayEndpointTag: _set_gateway_endpoint_tag,
|
|
DeleteGatewayEndpointTag: _delete_gateway_endpoint_tag,
|
|
# Budget Policy APIs
|
|
CreateGatewayBudgetPolicy: _create_budget_policy,
|
|
GetGatewayBudgetPolicy: _get_budget_policy,
|
|
UpdateGatewayBudgetPolicy: _update_budget_policy,
|
|
DeleteGatewayBudgetPolicy: _delete_budget_policy,
|
|
ListGatewayBudgetPolicies: _list_budget_policies,
|
|
ListGatewayBudgetWindows: _list_budget_windows,
|
|
# Guardrail APIs
|
|
CreateGatewayGuardrail: _create_gateway_guardrail,
|
|
GetGatewayGuardrail: _get_gateway_guardrail,
|
|
DeleteGatewayGuardrail: _delete_gateway_guardrail,
|
|
ListGatewayGuardrails: _list_gateway_guardrails,
|
|
AddGuardrailToEndpoint: _add_guardrail_to_endpoint,
|
|
RemoveGuardrailFromEndpoint: _remove_guardrail_from_endpoint,
|
|
ListEndpointGuardrailConfigs: _list_endpoint_guardrail_configs,
|
|
UpdateEndpointGuardrailConfig: _update_endpoint_guardrail_config,
|
|
# Prompt Optimization APIs
|
|
CreatePromptOptimizationJob: _create_prompt_optimization_job,
|
|
GetPromptOptimizationJob: _get_prompt_optimization_job,
|
|
SearchPromptOptimizationJobs: _search_prompt_optimization_jobs,
|
|
CancelPromptOptimizationJob: _cancel_prompt_optimization_job,
|
|
DeletePromptOptimizationJob: _delete_prompt_optimization_job,
|
|
# Workspace APIs
|
|
ListWorkspaces: _list_workspaces_handler,
|
|
CreateWorkspace: _create_workspace_handler,
|
|
GetWorkspace: _get_workspace_handler,
|
|
UpdateWorkspace: _update_workspace_handler,
|
|
DeleteWorkspace: _delete_workspace_handler,
|
|
}
|