mlrun 1.10.0rc19__py3-none-any.whl → 1.10.0rc21__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mlrun might be problematic. Click here for more details.

Files changed (31) hide show
  1. mlrun/common/schemas/function.py +10 -0
  2. mlrun/common/schemas/model_monitoring/constants.py +4 -11
  3. mlrun/common/schemas/model_monitoring/model_endpoints.py +2 -0
  4. mlrun/datastore/model_provider/huggingface_provider.py +109 -20
  5. mlrun/datastore/model_provider/model_provider.py +110 -32
  6. mlrun/datastore/model_provider/openai_provider.py +87 -31
  7. mlrun/db/base.py +0 -19
  8. mlrun/db/httpdb.py +10 -46
  9. mlrun/db/nopdb.py +0 -10
  10. mlrun/launcher/base.py +0 -6
  11. mlrun/model_monitoring/api.py +43 -22
  12. mlrun/model_monitoring/applications/base.py +1 -1
  13. mlrun/model_monitoring/controller.py +112 -38
  14. mlrun/model_monitoring/db/_schedules.py +13 -9
  15. mlrun/model_monitoring/stream_processing.py +16 -12
  16. mlrun/platforms/__init__.py +3 -2
  17. mlrun/projects/project.py +2 -2
  18. mlrun/run.py +38 -5
  19. mlrun/serving/server.py +23 -0
  20. mlrun/serving/states.py +76 -29
  21. mlrun/serving/system_steps.py +60 -36
  22. mlrun/utils/helpers.py +27 -13
  23. mlrun/utils/notifications/notification_pusher.py +1 -1
  24. mlrun/utils/version/version.json +2 -2
  25. {mlrun-1.10.0rc19.dist-info → mlrun-1.10.0rc21.dist-info}/METADATA +6 -5
  26. {mlrun-1.10.0rc19.dist-info → mlrun-1.10.0rc21.dist-info}/RECORD +30 -31
  27. mlrun/api/schemas/__init__.py +0 -259
  28. {mlrun-1.10.0rc19.dist-info → mlrun-1.10.0rc21.dist-info}/WHEEL +0 -0
  29. {mlrun-1.10.0rc19.dist-info → mlrun-1.10.0rc21.dist-info}/entry_points.txt +0 -0
  30. {mlrun-1.10.0rc19.dist-info → mlrun-1.10.0rc21.dist-info}/licenses/LICENSE +0 -0
  31. {mlrun-1.10.0rc19.dist-info → mlrun-1.10.0rc21.dist-info}/top_level.txt +0 -0
@@ -162,19 +162,19 @@ class ModelMonitoringSchedulesFileEndpoint(ModelMonitoringSchedulesFileBase):
162
162
  endpoint_id=model_endpoint.metadata.uid,
163
163
  )
164
164
 
165
- def get_application_time(self, application: str) -> Optional[int]:
165
+ def get_application_time(self, application: str) -> Optional[float]:
166
166
  self._check_open_schedules()
167
167
  return self._schedules.get(application)
168
168
 
169
- def update_application_time(self, application: str, timestamp: int) -> None:
169
+ def update_application_time(self, application: str, timestamp: float) -> None:
170
170
  self._check_open_schedules()
171
- self._schedules[application] = timestamp
171
+ self._schedules[application] = float(timestamp)
172
172
 
173
173
  def get_application_list(self) -> set[str]:
174
174
  self._check_open_schedules()
175
175
  return set(self._schedules.keys())
176
176
 
177
- def get_min_timestamp(self) -> Optional[int]:
177
+ def get_min_timestamp(self) -> Optional[float]:
178
178
  self._check_open_schedules()
179
179
  return min(self._schedules.values(), default=None)
180
180
 
@@ -198,7 +198,7 @@ class ModelMonitoringSchedulesFileChief(ModelMonitoringSchedulesFileBase):
198
198
  project=self._project
199
199
  )
200
200
 
201
- def get_endpoint_last_request(self, endpoint_uid: str) -> Optional[int]:
201
+ def get_endpoint_last_request(self, endpoint_uid: str) -> Optional[float]:
202
202
  self._check_open_schedules()
203
203
  if endpoint_uid in self._schedules:
204
204
  return self._schedules[endpoint_uid].get(
@@ -208,15 +208,19 @@ class ModelMonitoringSchedulesFileChief(ModelMonitoringSchedulesFileBase):
208
208
  return None
209
209
 
210
210
  def update_endpoint_timestamps(
211
- self, endpoint_uid: str, last_request: int, last_analyzed: int
211
+ self, endpoint_uid: str, last_request: float, last_analyzed: float
212
212
  ) -> None:
213
213
  self._check_open_schedules()
214
214
  self._schedules[endpoint_uid] = {
215
- schemas.model_monitoring.constants.ScheduleChiefFields.LAST_REQUEST: last_request,
216
- schemas.model_monitoring.constants.ScheduleChiefFields.LAST_ANALYZED: last_analyzed,
215
+ schemas.model_monitoring.constants.ScheduleChiefFields.LAST_REQUEST: float(
216
+ last_request
217
+ ),
218
+ schemas.model_monitoring.constants.ScheduleChiefFields.LAST_ANALYZED: float(
219
+ last_analyzed
220
+ ),
217
221
  }
218
222
 
219
- def get_endpoint_last_analyzed(self, endpoint_uid: str) -> Optional[int]:
223
+ def get_endpoint_last_analyzed(self, endpoint_uid: str) -> Optional[float]:
220
224
  self._check_open_schedules()
221
225
  if endpoint_uid in self._schedules:
222
226
  return self._schedules[endpoint_uid].get(
@@ -396,6 +396,8 @@ class ProcessEndpointEvent(mlrun.feature_store.steps.MapClass):
396
396
  request_id = event.get("request", {}).get("id") or event.get("resp", {}).get(
397
397
  "id"
398
398
  )
399
+ feature_names = event.get("request", {}).get("input_schema")
400
+ labels_names = event.get("resp", {}).get("output_schema")
399
401
  latency = event.get("microsec")
400
402
  features = event.get("request", {}).get("inputs")
401
403
  predictions = event.get("resp", {}).get("outputs")
@@ -496,6 +498,8 @@ class ProcessEndpointEvent(mlrun.feature_store.steps.MapClass):
496
498
  ),
497
499
  EventFieldType.EFFECTIVE_SAMPLE_COUNT: effective_sample_count,
498
500
  EventFieldType.ESTIMATED_PREDICTION_COUNT: estimated_prediction_count,
501
+ EventFieldType.FEATURE_NAMES: feature_names,
502
+ EventFieldType.LABEL_NAMES: labels_names,
499
503
  }
500
504
  )
501
505
 
@@ -602,19 +606,19 @@ class MapFeatureNames(mlrun.feature_store.steps.MapClass):
602
606
  self.endpoint_type = {}
603
607
 
604
608
  def _infer_feature_names_from_data(self, event):
605
- for endpoint_id in self.feature_names:
606
- if len(self.feature_names[endpoint_id]) >= len(
607
- event[EventFieldType.FEATURES]
608
- ):
609
- return self.feature_names[endpoint_id]
609
+ endpoint_id = event[EventFieldType.ENDPOINT_ID]
610
+ if endpoint_id in self.feature_names and len(
611
+ self.feature_names[endpoint_id]
612
+ ) >= len(event[EventFieldType.FEATURES]):
613
+ return self.feature_names[endpoint_id]
610
614
  return None
611
615
 
612
616
  def _infer_label_columns_from_data(self, event):
613
- for endpoint_id in self.label_columns:
614
- if len(self.label_columns[endpoint_id]) >= len(
615
- event[EventFieldType.PREDICTION]
616
- ):
617
- return self.label_columns[endpoint_id]
617
+ endpoint_id = event[EventFieldType.ENDPOINT_ID]
618
+ if endpoint_id in self.label_columns and len(
619
+ self.label_columns[endpoint_id]
620
+ ) >= len(event[EventFieldType.PREDICTION]):
621
+ return self.label_columns[endpoint_id]
618
622
  return None
619
623
 
620
624
  def do(self, event: dict):
@@ -659,7 +663,7 @@ class MapFeatureNames(mlrun.feature_store.steps.MapClass):
659
663
  "Feature names are not initialized, they will be automatically generated",
660
664
  endpoint_id=endpoint_id,
661
665
  )
662
- feature_names = [
666
+ feature_names = event.get(EventFieldType.FEATURE_NAMES) or [
663
667
  f"f{i}" for i, _ in enumerate(event[EventFieldType.FEATURES])
664
668
  ]
665
669
 
@@ -682,7 +686,7 @@ class MapFeatureNames(mlrun.feature_store.steps.MapClass):
682
686
  "label column names are not initialized, they will be automatically generated",
683
687
  endpoint_id=endpoint_id,
684
688
  )
685
- label_columns = [
689
+ label_columns = event.get(EventFieldType.LABEL_NAMES) or [
686
690
  f"p{i}" for i, _ in enumerate(event[EventFieldType.PREDICTION])
687
691
  ]
688
692
  attributes_to_update[EventFieldType.LABEL_NAMES] = label_columns
@@ -25,6 +25,7 @@ from .iguazio import (
25
25
  )
26
26
 
27
27
 
28
+ # TODO: Remove in 1.11.0
28
29
  class _DeprecationHelper:
29
30
  """A helper class to deprecate old schemas"""
30
31
 
@@ -48,12 +49,12 @@ class _DeprecationHelper:
48
49
  def _warn(self):
49
50
  warnings.warn(
50
51
  f"mlrun.platforms.{self._new_target} is deprecated since version {self._version}, "
51
- f"and will be removed in 1.10. Use mlrun.runtimes.mounts.{self._new_target} instead.",
52
+ f"and will be removed in 1.11.0. Use mlrun.runtimes.mounts.{self._new_target} instead.",
52
53
  FutureWarning,
53
54
  )
54
55
 
55
56
 
56
- # TODO: Remove in 1.10
57
+ # TODO: Remove in 1.11.0
57
58
  # For backwards compatibility
58
59
  VolumeMount = _DeprecationHelper("VolumeMount")
59
60
  auto_mount = _DeprecationHelper("auto_mount")
mlrun/projects/project.py CHANGED
@@ -3939,8 +3939,8 @@ class MlrunProject(ModelObj):
3939
3939
  :param start: The start time to filter by.Corresponding to the `created` field.
3940
3940
  :param end: The end time to filter by. Corresponding to the `created` field.
3941
3941
  :param top_level: If true will return only routers and endpoint that are NOT children of any router.
3942
- :param mode: Specifies the mode of the model endpoint. Can be "real-time", "batch", or both if set
3943
- to None.
3942
+ :param mode: Specifies the mode of the model endpoint. Can be "real-time" (0), "batch" (1), or
3943
+ both if set to None.
3944
3944
  :param uids: If passed will return a list `ModelEndpoint` object with uid in uids.
3945
3945
  :param tsdb_metrics: When True, the time series metrics will be added to the output
3946
3946
  of the resulting.
mlrun/run.py CHANGED
@@ -141,7 +141,7 @@ def load_func_code(command="", workdir=None, secrets=None, name="name"):
141
141
  else:
142
142
  is_remote = "://" in command
143
143
  data = get_object(command, secrets)
144
- runtime = yaml.load(data, Loader=yaml.FullLoader)
144
+ runtime = yaml.safe_load(data)
145
145
  runtime = new_function(runtime=runtime)
146
146
 
147
147
  command = runtime.spec.command or ""
@@ -362,7 +362,10 @@ def import_function(url="", secrets=None, db="", project=None, new_name=None):
362
362
  return function
363
363
 
364
364
 
365
- def import_function_to_dict(url, secrets=None):
365
+ def import_function_to_dict(
366
+ url: str,
367
+ secrets: Optional[dict] = None,
368
+ ) -> dict:
366
369
  """Load function spec from local/remote YAML file"""
367
370
  obj = get_object(url, secrets)
368
371
  runtime = yaml.safe_load(obj)
@@ -388,6 +391,11 @@ def import_function_to_dict(url, secrets=None):
388
391
  raise ValueError("exec path (spec.command) must be relative")
389
392
  url = url[: url.rfind("/") + 1] + code_file
390
393
  code = get_object(url, secrets)
394
+ code_file = _ensure_path_confined_to_base_dir(
395
+ base_directory=".",
396
+ relative_path=code_file,
397
+ error_message_on_escape="Path traversal detected in spec.command",
398
+ )
391
399
  dir = path.dirname(code_file)
392
400
  if dir:
393
401
  makedirs(dir, exist_ok=True)
@@ -395,9 +403,16 @@ def import_function_to_dict(url, secrets=None):
395
403
  fp.write(code)
396
404
  elif cmd:
397
405
  if not path.isfile(code_file):
398
- # look for the file in a relative path to the yaml
399
- slash = url.rfind("/")
400
- if slash >= 0 and path.isfile(url[: url.rfind("/") + 1] + code_file):
406
+ slash_index = url.rfind("/")
407
+ if slash_index < 0:
408
+ raise ValueError(f"no file in exec path (spec.command={code_file})")
409
+ base_dir = os.path.normpath(url[: slash_index + 1])
410
+ candidate_path = _ensure_path_confined_to_base_dir(
411
+ base_directory=base_dir,
412
+ relative_path=code_file,
413
+ error_message_on_escape=f"exec file spec.command={code_file} is outside of allowed directory",
414
+ )
415
+ if path.isfile(candidate_path):
401
416
  raise ValueError(
402
417
  f"exec file spec.command={code_file} is relative, change working dir"
403
418
  )
@@ -1258,3 +1273,21 @@ def wait_for_runs_completion(
1258
1273
  runs = running
1259
1274
 
1260
1275
  return completed
1276
+
1277
+
1278
+ def _ensure_path_confined_to_base_dir(
1279
+ base_directory: str,
1280
+ relative_path: str,
1281
+ error_message_on_escape: str,
1282
+ ) -> str:
1283
+ """
1284
+ Join `user_supplied_relative_path` to `allowed_base_directory`, normalise the result,
1285
+ and guarantee it stays inside `allowed_base_directory`.
1286
+ """
1287
+ absolute_base_directory = path.abspath(base_directory)
1288
+ absolute_candidate_path = path.abspath(
1289
+ path.join(absolute_base_directory, relative_path)
1290
+ )
1291
+ if not absolute_candidate_path.startswith(absolute_base_directory + path.sep):
1292
+ raise ValueError(error_message_on_escape)
1293
+ return absolute_candidate_path
mlrun/serving/server.py CHANGED
@@ -746,6 +746,26 @@ async def async_execute_graph(
746
746
  return responses
747
747
 
748
748
 
749
+ def _is_inside_asyncio_loop():
750
+ try:
751
+ asyncio.get_running_loop()
752
+ return True
753
+ except RuntimeError:
754
+ return False
755
+
756
+
757
+ # Workaround for running with local=True in Jupyter (ML-10620)
758
+ def _workaround_asyncio_nesting():
759
+ try:
760
+ import nest_asyncio
761
+ except ImportError:
762
+ raise mlrun.errors.MLRunRuntimeError(
763
+ "Cannot execute graph from within an already running asyncio loop. "
764
+ "Attempt to import nest_asyncio as a workaround failed as well."
765
+ )
766
+ nest_asyncio.apply()
767
+
768
+
749
769
  def execute_graph(
750
770
  context: MLClientCtx,
751
771
  data: DataItem,
@@ -771,6 +791,9 @@ def execute_graph(
771
791
 
772
792
  :return: A list of responses.
773
793
  """
794
+ if _is_inside_asyncio_loop():
795
+ _workaround_asyncio_nesting()
796
+
774
797
  return asyncio.run(
775
798
  async_execute_graph(
776
799
  context,
mlrun/serving/states.py CHANGED
@@ -24,6 +24,7 @@ import inspect
24
24
  import os
25
25
  import pathlib
26
26
  import traceback
27
+ import warnings
27
28
  from abc import ABC
28
29
  from copy import copy, deepcopy
29
30
  from inspect import getfullargspec, signature
@@ -43,9 +44,13 @@ from mlrun.datastore.datastore_profile import (
43
44
  DatastoreProfileV3io,
44
45
  datastore_profile_read,
45
46
  )
46
- from mlrun.datastore.model_provider.model_provider import ModelProvider
47
+ from mlrun.datastore.model_provider.model_provider import (
48
+ InvokeResponseFormat,
49
+ ModelProvider,
50
+ UsageResponseKeys,
51
+ )
47
52
  from mlrun.datastore.storeytargets import KafkaStoreyTarget, StreamStoreyTarget
48
- from mlrun.utils import get_data_from_path, logger, split_path
53
+ from mlrun.utils import get_data_from_path, logger, set_data_by_path, split_path
49
54
 
50
55
  from ..config import config
51
56
  from ..datastore import _DummyStream, get_stream_pusher
@@ -1206,10 +1211,15 @@ class Model(storey.ParallelExecutionRunnable, ModelObj):
1206
1211
 
1207
1212
  class LLModel(Model):
1208
1213
  def __init__(
1209
- self, name: str, input_path: Optional[Union[str, list[str]]] = None, **kwargs
1214
+ self,
1215
+ name: str,
1216
+ input_path: Optional[Union[str, list[str]]] = None,
1217
+ result_path: Optional[Union[str, list[str]]] = None,
1218
+ **kwargs,
1210
1219
  ):
1211
1220
  super().__init__(name, **kwargs)
1212
1221
  self._input_path = split_path(input_path)
1222
+ self._result_path = split_path(result_path)
1213
1223
 
1214
1224
  def predict(
1215
1225
  self,
@@ -1221,11 +1231,14 @@ class LLModel(Model):
1221
1231
  if isinstance(
1222
1232
  self.invocation_artifact, mlrun.artifacts.LLMPromptArtifact
1223
1233
  ) and isinstance(self.model_provider, ModelProvider):
1224
- body["result"] = self.model_provider.invoke(
1234
+ response_with_stats = self.model_provider.invoke(
1225
1235
  messages=messages,
1226
- as_str=True,
1236
+ invoke_response_format=InvokeResponseFormat.USAGE,
1227
1237
  **(model_configuration or {}),
1228
1238
  )
1239
+ set_data_by_path(
1240
+ path=self._result_path, data=body, value=response_with_stats
1241
+ )
1229
1242
  return body
1230
1243
 
1231
1244
  async def predict_async(
@@ -1238,11 +1251,14 @@ class LLModel(Model):
1238
1251
  if isinstance(
1239
1252
  self.invocation_artifact, mlrun.artifacts.LLMPromptArtifact
1240
1253
  ) and isinstance(self.model_provider, ModelProvider):
1241
- body["result"] = await self.model_provider.async_invoke(
1254
+ response_with_stats = await self.model_provider.async_invoke(
1242
1255
  messages=messages,
1243
- as_str=True,
1256
+ invoke_response_format=InvokeResponseFormat.USAGE,
1244
1257
  **(model_configuration or {}),
1245
1258
  )
1259
+ set_data_by_path(
1260
+ path=self._result_path, data=body, value=response_with_stats
1261
+ )
1246
1262
  return body
1247
1263
 
1248
1264
  def run(self, body: Any, path: str, origin_name: Optional[str] = None) -> Any:
@@ -1609,6 +1625,9 @@ class ModelRunnerStep(MonitoredStep):
1609
1625
  :param outputs: list of the model outputs (e.g. labels) ,if provided will override the outputs
1610
1626
  that been configured in the model artifact, please note that those outputs need to
1611
1627
  be equal to the model_class predict method outputs (length, and order)
1628
+
1629
+ When using LLModel, the output will be overridden with UsageResponseKeys.fields().
1630
+
1612
1631
  :param input_path: when specified selects the key/path in the event to use as model monitoring inputs
1613
1632
  this require that the event body will behave like a dict, expects scopes to be
1614
1633
  defined by dot notation (e.g "data.d").
@@ -1637,7 +1656,14 @@ class ModelRunnerStep(MonitoredStep):
1637
1656
  raise mlrun.errors.MLRunInvalidArgumentError(
1638
1657
  "Cannot provide a model object as argument to `model_class` and also provide `model_parameters`."
1639
1658
  )
1640
-
1659
+ if type(model_class) is LLModel or (
1660
+ isinstance(model_class, str) and model_class == LLModel.__name__
1661
+ ):
1662
+ if outputs:
1663
+ warnings.warn(
1664
+ "LLModel with existing outputs detected, overriding to default"
1665
+ )
1666
+ outputs = UsageResponseKeys.fields()
1641
1667
  model_parameters = model_parameters or (
1642
1668
  model_class.to_dict() if isinstance(model_class, Model) else {}
1643
1669
  )
@@ -1653,8 +1679,6 @@ class ModelRunnerStep(MonitoredStep):
1653
1679
  except mlrun.errors.MLRunNotFoundError:
1654
1680
  raise mlrun.errors.MLRunInvalidArgumentError("Artifact not found.")
1655
1681
 
1656
- outputs = outputs or self._get_model_output_schema(model_artifact)
1657
-
1658
1682
  model_artifact = (
1659
1683
  model_artifact.uri
1660
1684
  if isinstance(model_artifact, mlrun.artifacts.Artifact)
@@ -1720,28 +1744,13 @@ class ModelRunnerStep(MonitoredStep):
1720
1744
  self.class_args[schemas.ModelRunnerStepData.MONITORING_DATA] = monitoring_data
1721
1745
 
1722
1746
  @staticmethod
1723
- def _get_model_output_schema(
1724
- model_artifact: Union[ModelArtifact, LLMPromptArtifact],
1725
- ) -> Optional[list[str]]:
1726
- if isinstance(
1727
- model_artifact,
1728
- ModelArtifact,
1729
- ):
1730
- return [feature.name for feature in model_artifact.spec.outputs]
1731
- elif isinstance(
1732
- model_artifact,
1733
- LLMPromptArtifact,
1734
- ):
1735
- _model_artifact = model_artifact.model_artifact
1736
- return [feature.name for feature in _model_artifact.spec.outputs]
1737
-
1738
- @staticmethod
1739
- def _get_model_endpoint_output_schema(
1747
+ def _get_model_endpoint_schema(
1740
1748
  name: str,
1741
1749
  project: str,
1742
1750
  uid: str,
1743
- ) -> list[str]:
1751
+ ) -> tuple[list[str], list[str]]:
1744
1752
  output_schema = None
1753
+ input_schema = None
1745
1754
  try:
1746
1755
  model_endpoint: mlrun.common.schemas.model_monitoring.ModelEndpoint = (
1747
1756
  mlrun.db.get_run_db().get_model_endpoint(
@@ -1752,6 +1761,7 @@ class ModelRunnerStep(MonitoredStep):
1752
1761
  )
1753
1762
  )
1754
1763
  output_schema = model_endpoint.spec.label_names
1764
+ input_schema = model_endpoint.spec.feature_names
1755
1765
  except (
1756
1766
  mlrun.errors.MLRunNotFoundError,
1757
1767
  mlrun.errors.MLRunInvalidArgumentError,
@@ -1760,7 +1770,7 @@ class ModelRunnerStep(MonitoredStep):
1760
1770
  f"Model endpoint not found, using default output schema for model {name}",
1761
1771
  error=f"{type(ex).__name__}: {ex}",
1762
1772
  )
1763
- return output_schema
1773
+ return input_schema, output_schema
1764
1774
 
1765
1775
  def _calculate_monitoring_data(self) -> dict[str, dict[str, str]]:
1766
1776
  monitoring_data = deepcopy(
@@ -1776,6 +1786,36 @@ class ModelRunnerStep(MonitoredStep):
1776
1786
  monitoring_data[model][schemas.MonitoringData.RESULT_PATH] = split_path(
1777
1787
  monitoring_data[model][schemas.MonitoringData.RESULT_PATH]
1778
1788
  )
1789
+
1790
+ mep_output_schema, mep_input_schema = None, None
1791
+
1792
+ output_schema = self.class_args[
1793
+ mlrun.common.schemas.ModelRunnerStepData.MONITORING_DATA
1794
+ ][model][schemas.MonitoringData.OUTPUTS]
1795
+ input_schema = self.class_args[
1796
+ mlrun.common.schemas.ModelRunnerStepData.MONITORING_DATA
1797
+ ][model][schemas.MonitoringData.INPUTS]
1798
+ if not output_schema or not input_schema:
1799
+ # if output or input schema is not provided, try to get it from the model endpoint
1800
+ mep_input_schema, mep_output_schema = (
1801
+ self._get_model_endpoint_schema(
1802
+ model,
1803
+ self.context.project,
1804
+ monitoring_data[model].get(
1805
+ schemas.MonitoringData.MODEL_ENDPOINT_UID, ""
1806
+ ),
1807
+ )
1808
+ )
1809
+ self.class_args[
1810
+ mlrun.common.schemas.ModelRunnerStepData.MONITORING_DATA
1811
+ ][model][schemas.MonitoringData.OUTPUTS] = (
1812
+ output_schema or mep_output_schema
1813
+ )
1814
+ self.class_args[
1815
+ mlrun.common.schemas.ModelRunnerStepData.MONITORING_DATA
1816
+ ][model][schemas.MonitoringData.INPUTS] = (
1817
+ input_schema or mep_input_schema
1818
+ )
1779
1819
  return monitoring_data
1780
1820
  else:
1781
1821
  raise mlrun.errors.MLRunInvalidArgumentError(
@@ -1803,6 +1843,13 @@ class ModelRunnerStep(MonitoredStep):
1803
1843
  .get(model_params.get("name"), {})
1804
1844
  .get(schemas.MonitoringData.INPUT_PATH)
1805
1845
  )
1846
+ model_params[schemas.MonitoringData.RESULT_PATH] = (
1847
+ self.class_args.get(
1848
+ mlrun.common.schemas.ModelRunnerStepData.MONITORING_DATA, {}
1849
+ )
1850
+ .get(model_params.get("name"), {})
1851
+ .get(schemas.MonitoringData.RESULT_PATH)
1852
+ )
1806
1853
  model = get_class(model, namespace).from_dict(
1807
1854
  model_params, init_with_params=True
1808
1855
  )
@@ -13,6 +13,7 @@
13
13
  # limitations under the License.
14
14
 
15
15
  import random
16
+ from copy import copy
16
17
  from datetime import timedelta
17
18
  from typing import Any, Optional, Union
18
19
 
@@ -22,6 +23,7 @@ import storey
22
23
  import mlrun
23
24
  import mlrun.artifacts
24
25
  import mlrun.common.schemas.model_monitoring as mm_schemas
26
+ import mlrun.feature_store
25
27
  import mlrun.serving
26
28
  from mlrun.common.schemas import MonitoringData
27
29
  from mlrun.utils import get_data_from_path, logger
@@ -45,33 +47,20 @@ class MonitoringPreProcessor(storey.MapClass):
45
47
  result_path = model_monitoring_data.get(MonitoringData.RESULT_PATH)
46
48
  input_path = model_monitoring_data.get(MonitoringData.INPUT_PATH)
47
49
 
48
- result = get_data_from_path(result_path, event.body.get(model, event.body))
49
50
  output_schema = model_monitoring_data.get(MonitoringData.OUTPUTS)
50
51
  input_schema = model_monitoring_data.get(MonitoringData.INPUTS)
51
- logger.debug("output schema retrieved", output_schema=output_schema)
52
- if isinstance(result, dict):
53
- # transpose by key the outputs:
54
- outputs = self.transpose_by_key(result, output_schema)
55
- if not output_schema:
56
- logger.warn(
57
- "Output schema was not provided using Project:log_model or by ModelRunnerStep:add_model order "
58
- "may not preserved"
59
- )
60
- else:
61
- outputs = result
52
+ logger.debug(
53
+ "output and input schema retrieved",
54
+ output_schema=output_schema,
55
+ input_schema=input_schema,
56
+ )
62
57
 
63
- event_inputs = event._metadata.get("inputs", {})
64
- event_inputs = get_data_from_path(input_path, event_inputs)
65
- if isinstance(event_inputs, dict):
66
- # transpose by key the inputs:
67
- inputs = self.transpose_by_key(event_inputs, input_schema)
68
- if not input_schema:
69
- logger.warn(
70
- "Input schema was not provided using by ModelRunnerStep:add_model, order "
71
- "may not preserved"
72
- )
73
- else:
74
- inputs = event_inputs
58
+ outputs, new_output_schema = self.get_listed_data(
59
+ event.body.get(model, event.body), result_path, output_schema
60
+ )
61
+ inputs, new_input_schema = self.get_listed_data(
62
+ event._metadata.get("inputs", {}), input_path, input_schema
63
+ )
75
64
 
76
65
  if outputs and isinstance(outputs[0], list):
77
66
  if output_schema and len(output_schema) != len(outputs[0]):
@@ -96,15 +85,43 @@ class MonitoringPreProcessor(storey.MapClass):
96
85
  "outputs and inputs are not in the same length check 'input_path' and "
97
86
  "'output_path' was specified if needed"
98
87
  )
99
- request = {"inputs": inputs, "id": getattr(event, "id", None)}
100
- resp = {"outputs": outputs}
88
+ request = {
89
+ "inputs": inputs,
90
+ "id": getattr(event, "id", None),
91
+ "input_schema": new_input_schema,
92
+ }
93
+ resp = {"outputs": outputs, "output_schema": new_output_schema}
101
94
 
102
95
  return request, resp
103
96
 
97
+ def get_listed_data(
98
+ self,
99
+ raw_data: dict,
100
+ data_path: Optional[Union[list[str], str]] = None,
101
+ schema: Optional[list[str]] = None,
102
+ ):
103
+ """Get data from a path and transpose it by keys if dict is provided."""
104
+ new_schema = None
105
+ data_from_path = get_data_from_path(data_path, raw_data)
106
+ if isinstance(data_from_path, dict):
107
+ # transpose by key the inputs:
108
+ listed_data, new_schema = self.transpose_by_key(data_from_path, schema)
109
+ new_schema = new_schema or schema
110
+ if not schema:
111
+ logger.warn(
112
+ f"No schema provided through add_model(); the order of {data_from_path} "
113
+ "may not be preserved."
114
+ )
115
+ elif not isinstance(data_from_path, list):
116
+ listed_data = [data_from_path]
117
+ else:
118
+ listed_data = data_from_path
119
+ return listed_data, new_schema
120
+
104
121
  @staticmethod
105
122
  def transpose_by_key(
106
123
  data: dict, schema: Optional[Union[str, list[str]]] = None
107
- ) -> Union[list[Any], list[list[Any]]]:
124
+ ) -> tuple[Union[list[Any], list[list[Any]]], list[str]]:
108
125
  """
109
126
  Transpose values from a dictionary by keys.
110
127
 
@@ -136,20 +153,27 @@ class MonitoringPreProcessor(storey.MapClass):
136
153
  * If result is a matrix, returns a list of lists.
137
154
 
138
155
  :raises ValueError: If the values include a mix of scalars and lists, or if the list lengths do not match.
156
+ mlrun.MLRunInvalidArgumentError if the schema keys are not contained in the data keys.
139
157
  """
140
-
158
+ new_schema = None
159
+ # Normalize keys in data:
160
+ normalize_data = {
161
+ mlrun.feature_store.api.norm_column_name(k): copy(v)
162
+ for k, v in data.items()
163
+ }
141
164
  # Normalize schema to list
142
165
  if not schema:
143
- keys = list(data.keys())
166
+ keys = list(normalize_data.keys())
167
+ new_schema = keys
144
168
  elif isinstance(schema, str):
145
- keys = [schema]
169
+ keys = [mlrun.feature_store.api.norm_column_name(schema)]
146
170
  else:
147
- keys = schema
171
+ keys = [mlrun.feature_store.api.norm_column_name(key) for key in schema]
148
172
 
149
- values = [data[key] for key in keys if key in data]
173
+ values = [normalize_data[key] for key in keys if key in normalize_data]
150
174
  if len(values) != len(keys):
151
175
  raise mlrun.MLRunInvalidArgumentError(
152
- f"Schema keys {keys} do not match the data keys {list(data.keys())}."
176
+ f"Schema keys {keys} are not contained in the data keys {list(data.keys())}."
153
177
  )
154
178
 
155
179
  # Detect if all are scalars ie: int,float,str
@@ -168,12 +192,12 @@ class MonitoringPreProcessor(storey.MapClass):
168
192
  mat = np.stack(arrays, axis=0)
169
193
  transposed = mat.T
170
194
  else:
171
- return values[0]
195
+ return values[0], new_schema
172
196
 
173
197
  if transposed.shape[1] == 1 and transposed.shape[0] == 1:
174
198
  # Transform [[0]] -> [0]:
175
- return transposed[:, 0].tolist()
176
- return transposed.tolist()
199
+ return transposed[:, 0].tolist(), new_schema
200
+ return transposed.tolist(), new_schema
177
201
 
178
202
  def do(self, event):
179
203
  monitoring_event_list = []