diff --git a/ads/aqua/model/model.py b/ads/aqua/model/model.py index f7204fd72..d9b12ca6e 100644 --- a/ads/aqua/model/model.py +++ b/ads/aqua/model/model.py @@ -141,19 +141,19 @@ class AquaModelApp(AquaApp): @telemetry(entry_point="plugin=model&action=create", name="aqua") def create( self, - model_id: Union[str, AquaMultiModelRef], + model: Union[str, AquaMultiModelRef], project_id: Optional[str] = None, compartment_id: Optional[str] = None, freeform_tags: Optional[Dict] = None, defined_tags: Optional[Dict] = None, **kwargs, - ) -> DataScienceModel: + ) -> Union[DataScienceModel, DataScienceModelGroup]: """ - Creates a custom Aqua model from a service model. + Creates a custom Aqua model or model group from a service model. Parameters ---------- - model_id : Union[str, AquaMultiModelRef] + model : Union[str, AquaMultiModelRef] The model ID as a string or a AquaMultiModelRef instance to be deployed. project_id : Optional[str] The project ID for the custom model. @@ -167,28 +167,18 @@ def create( Returns ------- - DataScienceModel - The instance of DataScienceModel. + Union[DataScienceModel, DataScienceModelGroup] + The instance of DataScienceModel or DataScienceModelGroup. """ - model_id = ( - model_id.model_id if isinstance(model_id, AquaMultiModelRef) else model_id - ) - service_model = DataScienceModel.from_id(model_id) + fine_tune_weights = [] + if isinstance(model, AquaMultiModelRef): + fine_tune_weights = model.fine_tune_weights + model = model.model_id + + service_model = DataScienceModel.from_id(model) target_project = project_id or PROJECT_OCID target_compartment = compartment_id or COMPARTMENT_OCID - # Skip model copying if it is registered model or fine-tuned model - if ( - service_model.freeform_tags.get(Tags.BASE_MODEL_CUSTOM, None) is not None - or service_model.freeform_tags.get(Tags.AQUA_FINE_TUNED_MODEL_TAG) - is not None - ): - logger.info( - f"Aqua Model {model_id} already exists in the user's compartment." - "Skipped copying." - ) - return service_model - # combine tags combined_freeform_tags = { **(service_model.freeform_tags or {}), @@ -199,29 +189,112 @@ def create( **(defined_tags or {}), } + custom_model = None + if fine_tune_weights: + custom_model = self._create_model_group( + model_id=model, + compartment_id=target_compartment, + project_id=target_project, + freeform_tags=combined_freeform_tags, + defined_tags=combined_defined_tags, + fine_tune_weights=fine_tune_weights, + service_model=service_model, + ) + + logger.info( + f"Aqua Model Group {custom_model.id} created with the service model {model}." + ) + else: + # Skip model copying if it is registered model or fine-tuned model + if ( + Tags.BASE_MODEL_CUSTOM in service_model.freeform_tags + or Tags.AQUA_FINE_TUNED_MODEL_TAG in service_model.freeform_tags + ): + logger.info( + f"Aqua Model {model} already exists in the user's compartment." + "Skipped copying." + ) + return service_model + + custom_model = self._create_model( + compartment_id=target_compartment, + project_id=target_project, + freeform_tags=combined_freeform_tags, + defined_tags=combined_defined_tags, + service_model=service_model, + **kwargs, + ) + logger.info( + f"Aqua Model {custom_model.id} created with the service model {model}." + ) + + # Track unique models that were created in the user's compartment + self.telemetry.record_event_async( + category="aqua/service/model", + action="create", + detail=service_model.display_name, + ) + + return custom_model + + def _create_model( + self, + compartment_id: str, + project_id: str, + freeform_tags: Dict, + defined_tags: Dict, + service_model: DataScienceModel, + **kwargs, + ): + """Creates a data science model by reference.""" custom_model = ( DataScienceModel() - .with_compartment_id(target_compartment) - .with_project_id(target_project) + .with_compartment_id(compartment_id) + .with_project_id(project_id) .with_model_file_description(json_dict=service_model.model_file_description) .with_display_name(service_model.display_name) .with_description(service_model.description) - .with_freeform_tags(**combined_freeform_tags) - .with_defined_tags(**combined_defined_tags) + .with_freeform_tags(**freeform_tags) + .with_defined_tags(**defined_tags) .with_custom_metadata_list(service_model.custom_metadata_list) .with_defined_metadata_list(service_model.defined_metadata_list) .with_provenance_metadata(service_model.provenance_metadata) .create(model_by_reference=True, **kwargs) ) - logger.info( - f"Aqua Model {custom_model.id} created with the service model {model_id}." - ) - # Track unique models that were created in the user's compartment - self.telemetry.record_event_async( - category="aqua/service/model", - action="create", - detail=service_model.display_name, + return custom_model + + def _create_model_group( + self, + model_id: str, + compartment_id: str, + project_id: str, + freeform_tags: Dict, + defined_tags: Dict, + fine_tune_weights: List, + service_model: DataScienceModel, + ): + """Creates a data science model group.""" + custom_model = ( + DataScienceModelGroup() + .with_compartment_id(compartment_id) + .with_project_id(project_id) + .with_display_name(service_model.display_name) + .with_description(service_model.description) + .with_freeform_tags(**freeform_tags) + .with_defined_tags(**defined_tags) + .with_custom_metadata_list(service_model.custom_metadata_list) + .with_base_model_id(model_id) + .with_member_models( + [ + { + "inference_key": fine_tune_weight.model_name, + "model_id": fine_tune_weight.model_id, + } + for fine_tune_weight in fine_tune_weights + ] + ) + .create() ) return custom_model @@ -271,6 +344,16 @@ def create_multi( DataScienceModelGroup Instance of DataScienceModelGroup object. """ + member_model_ids = [{"model_id": model.model_id} for model in models] + for model in models: + if model.fine_tune_weights: + member_model_ids.extend( + [ + {"model_id": fine_tune_model.model_id} + for fine_tune_model in model.fine_tune_weights + ] + ) + custom_model_group = ( DataScienceModelGroup() .with_compartment_id(compartment_id) @@ -281,7 +364,7 @@ def create_multi( .with_defined_tags(**(defined_tags or {})) .with_custom_metadata_list(model_custom_metadata) # TODO: add member model inference key - .with_member_models([{"model_id": model.model_id for model in models}]) + .with_member_models(member_model_ids) ) custom_model_group.create() diff --git a/ads/aqua/modeldeployment/constants.py b/ads/aqua/modeldeployment/constants.py index a37699301..1853c04b7 100644 --- a/ads/aqua/modeldeployment/constants.py +++ b/ads/aqua/modeldeployment/constants.py @@ -9,5 +9,12 @@ This module contains constants used in Aqua Model Deployment. """ +from ads.common.extended_enum import ExtendedEnum + DEFAULT_WAIT_TIME = 12000 DEFAULT_POLL_INTERVAL = 10 + + +class DeploymentType(ExtendedEnum): + STACKED = "STACKED" + MULTI = "MULTI" diff --git a/ads/aqua/modeldeployment/deployment.py b/ads/aqua/modeldeployment/deployment.py index c000c9059..a932c28c5 100644 --- a/ads/aqua/modeldeployment/deployment.py +++ b/ads/aqua/modeldeployment/deployment.py @@ -24,11 +24,13 @@ from ads.aqua.common.errors import AquaRuntimeError, AquaValueError from ads.aqua.common.utils import ( DEFINED_METADATA_TO_FILE_MAP, + build_params_string, build_pydantic_error_message, find_restricted_params, get_combined_params, get_container_params_type, get_ocid_substring, + get_params_dict, get_params_list, get_preferred_compatible_family, get_resource_name, @@ -61,7 +63,11 @@ ModelDeploymentConfigSummary, MultiModelDeploymentConfigLoader, ) -from ads.aqua.modeldeployment.constants import DEFAULT_POLL_INTERVAL, DEFAULT_WAIT_TIME +from ads.aqua.modeldeployment.constants import ( + DEFAULT_POLL_INTERVAL, + DEFAULT_WAIT_TIME, + DeploymentType, +) from ads.aqua.modeldeployment.entities import ( AquaDeployment, AquaDeploymentDetail, @@ -76,6 +82,7 @@ AQUA_DEPLOYMENT_CONTAINER_CMD_VAR_METADATA_NAME, AQUA_DEPLOYMENT_CONTAINER_METADATA_NAME, AQUA_DEPLOYMENT_CONTAINER_URI_METADATA_NAME, + AQUA_MODEL_DEPLOYMENT_FOLDER, AQUA_TELEMETRY_BUCKET, AQUA_TELEMETRY_BUCKET_NS, COMPARTMENT_OCID, @@ -162,6 +169,7 @@ def create( cmd_var (Optional[List[str]]): Command variables for the container runtime. freeform_tags (Optional[Dict]): Freeform tags for model deployment. defined_tags (Optional[Dict]): Defined tags for model deployment. + deployment_type (Optional[str]): The type of model deployment. Returns ------- @@ -206,13 +214,26 @@ def create( # Create an AquaModelApp instance once to perform the deployment creation. model_app = AquaModelApp() - if create_deployment_details.model_id: + if ( + create_deployment_details.model_id + or create_deployment_details.deployment_type == DeploymentType.STACKED + ): + model = create_deployment_details.model_id + if not model: + if len(create_deployment_details.models) != 1: + raise AquaValueError( + "Invalid 'models' provided. Only one base model is required for model stack deployment." + ) + model = create_deployment_details.models[0] + + service_model_id = model if isinstance(model, str) else model.model_id logger.debug( - f"Single model ({create_deployment_details.model_id}) provided. " + f"Single model ({service_model_id}) provided. " "Delegating to single model creation method." ) + aqua_model = model_app.create( - model_id=create_deployment_details.model_id, + model=model, compartment_id=compartment_id, project_id=project_id, freeform_tags=freeform_tags, @@ -223,6 +244,7 @@ def create( create_deployment_details=create_deployment_details, container_config=container_config, ) + # TODO: add multi model validation from deployment_type else: # Collect all unique model IDs (including fine-tuned models) source_model_ids = list( @@ -677,7 +699,7 @@ def _build_model_group_config( def _create( self, - aqua_model: DataScienceModel, + aqua_model: Union[DataScienceModel, DataScienceModelGroup], create_deployment_details: CreateModelDeploymentDetails, container_config: Dict, ) -> AquaDeployment: @@ -711,7 +733,10 @@ def _create( tags.update({Tags.TASK: aqua_model.freeform_tags.get(Tags.TASK, UNKNOWN)}) # Set up info to get deployment config - config_source_id = create_deployment_details.model_id + config_source_id = ( + create_deployment_details.model_id + or create_deployment_details.models[0].model_id + ) model_name = aqua_model.display_name # set up env and cmd var @@ -862,6 +887,20 @@ def _create( deployment_params = get_combined_params(config_params, user_params) params = f"{params} {deployment_params}".strip() + + if isinstance(aqua_model, DataScienceModelGroup): + env_var.update({"VLLM_ALLOW_RUNTIME_LORA_UPDATING": "true"}) + env_var.update( + {"MODEL": f"{AQUA_MODEL_DEPLOYMENT_FOLDER}{aqua_model.base_model_id}/"} + ) + + params_dict = get_params_dict(params) + # updates `--served-model-name` with service model id + params_dict.update({"--served-model-name": aqua_model.base_model_id}) + # adds `--enable_lora` to parameters + params_dict.update({"--enable_lora": UNKNOWN}) + params = build_params_string(params_dict) + if params: env_var.update({"PARAMS": params}) env_vars = container_spec.env_vars if container_spec else [] diff --git a/ads/aqua/modeldeployment/entities.py b/ads/aqua/modeldeployment/entities.py index 0b65bc213..3a95dc37c 100644 --- a/ads/aqua/modeldeployment/entities.py +++ b/ads/aqua/modeldeployment/entities.py @@ -325,6 +325,9 @@ class CreateModelDeploymentDetails(BaseModel): defined_tags: Optional[Dict] = Field( None, description="Defined tags for model deployment." ) + deployment_type: Optional[str] = Field( + None, description="The type of model deployment." + ) @model_validator(mode="before") @classmethod diff --git a/ads/model/datascience_model_group.py b/ads/model/datascience_model_group.py index cc32ffa9c..3443e42ec 100644 --- a/ads/model/datascience_model_group.py +++ b/ads/model/datascience_model_group.py @@ -22,6 +22,7 @@ ModelGroup, ModelGroupDetails, ModelGroupSummary, + StackedModelGroupDetails, UpdateModelGroupDetails, ) except ModuleNotFoundError as err: @@ -511,28 +512,39 @@ def create( def _build_model_group_details(self) -> dict: """Builds model group details dict for creating or updating oci model group.""" - model_group_details = HomogeneousModelGroupDetails( - custom_metadata_list=[ - CustomMetadata( - key=custom_metadata.key, - value=custom_metadata.value, - description=custom_metadata.description, - category=custom_metadata.category, - ) - for custom_metadata in self.custom_metadata_list._to_oci_metadata() - ] - ) + custom_metadata_list = [ + CustomMetadata( + key=custom_metadata.key, + value=custom_metadata.value, + description=custom_metadata.description, + category=custom_metadata.category, + ) + for custom_metadata in self.custom_metadata_list._to_oci_metadata() + ] + member_model_details = [ + MemberModelDetails(**member_model) for member_model in self.member_models + ] + + if self.base_model_id: + model_group_details = StackedModelGroupDetails( + custom_metadata_list=custom_metadata_list, + base_model_id=self.base_model_id, + ) + member_model_details.append(MemberModelDetails(model_id=self.base_model_id)) + else: + model_group_details = HomogeneousModelGroupDetails( + custom_metadata_list=custom_metadata_list + ) member_model_entries = MemberModelEntries( - member_model_details=[ - MemberModelDetails(**member_model) - for member_model in self.member_models - ] + member_model_details=member_model_details ) build_model_group_details = copy.deepcopy(self._spec) - build_model_group_details.pop(self.CONST_CUSTOM_METADATA_LIST) - build_model_group_details.pop(self.CONST_MEMBER_MODELS) + # pop out the unrequired specs for building `CreateModelGroupDetails` or `UpdateModelGroupDetails`. + build_model_group_details.pop(self.CONST_CUSTOM_METADATA_LIST, None) + build_model_group_details.pop(self.CONST_MEMBER_MODELS, None) + build_model_group_details.pop(self.CONST_BASE_MODEL_ID, None) build_model_group_details.update( { self.CONST_COMPARTMENT_ID: self.compartment_id or COMPARTMENT_OCID, @@ -581,6 +593,9 @@ def _update_from_oci_model( ) self.set_spec(self.CONST_CUSTOM_METADATA_LIST, model_custom_metadata) + if hasattr(model_group_details, "base_model_id"): + self.set_spec(self.CONST_BASE_MODEL_ID, model_group_details.base_model_id) + # only updates member_models when oci_model_group_instance is an instance of # oci.data_science.models.ModelGroup as oci.data_science.models.ModelGroupSummary # doesn't have member_model_entries property. diff --git a/tests/unitary/with_extras/aqua/test_deployment.py b/tests/unitary/with_extras/aqua/test_deployment.py index 5d71cbc27..5707240a8 100644 --- a/tests/unitary/with_extras/aqua/test_deployment.py +++ b/tests/unitary/with_extras/aqua/test_deployment.py @@ -13,6 +13,7 @@ from unittest.mock import MagicMock, patch from ads.aqua.modeldeployment.constants import DEFAULT_POLL_INTERVAL, DEFAULT_WAIT_TIME +from ads.model.datascience_model_group import DataScienceModelGroup from ads.model.service.oci_datascience_model_deployment import ( OCIDataScienceModelDeployment, ) @@ -1059,6 +1060,50 @@ class TestDataset: ], } + aqua_deployment_stack_model = { + "id": "ocid1.datasciencemodelgroupint.oc1.iad.", + "compartmentId": "ocid1.compartment.oc1..", + "projectId": "ocid1.datascienceproject.oc1.iad.", + "displayName": "model_group_20250715", + "description": "Multi-model grouping using meta-llama/Meta-Llama-3.1-8B, meta-llama/Meta-Llama-3.1-8B-Instruct.", + "freeformTags": {"aqua_multimodel": "true"}, + "lifecycleState": "ACTIVE", + "base_model_id": "ocid1.datasciencemodel.oc1.iad.", + "customMetadataList": ModelCustomMetadata.from_dict( + { + "data": [ + { + "key": "artifact_location", + "value": "service_models/model-name/artifact", + "description": "artifact location", + "category": "Other", + "has_artifact": False, + }, + { + "key": "modelDescription", + "value": True, + "description": "model by reference flag", + "category": "Other", + "has_artifact": False, + }, + { + "key": "deployment-container", + "value": "odsc-vllm-serving", + "description": "Deployment container mapping for SMC", + "category": "Other", + "has_artifact": False, + }, + ] + } + ), + "memberModels": [ + { + "inference_key": "custom_inference_key", + "model_id": "ocid1.datasciencemodel.oc1.iad.", + } + ], + } + class TestAquaDeployment(unittest.TestCase): def setUp(self): @@ -1470,7 +1515,7 @@ def test_create_deployment_for_foundation_model( ) mock_create.assert_called_with( - model_id=TestDataset.MODEL_ID, + model=TestDataset.MODEL_ID, compartment_id=TestDataset.USER_COMPARTMENT_ID, project_id=TestDataset.USER_PROJECT_ID, freeform_tags=freeform_tags, @@ -1566,7 +1611,7 @@ def test_create_deployment_for_fine_tuned_model( ) mock_create.assert_called_with( - model_id=TestDataset.MODEL_ID, + model=TestDataset.MODEL_ID, compartment_id=TestDataset.USER_COMPARTMENT_ID, project_id=TestDataset.USER_PROJECT_ID, freeform_tags=None, @@ -1662,7 +1707,7 @@ def test_create_deployment_for_gguf_model( ) mock_create.assert_called_with( - model_id=TestDataset.MODEL_ID, + model=TestDataset.MODEL_ID, compartment_id=TestDataset.USER_COMPARTMENT_ID, project_id=TestDataset.USER_PROJECT_ID, freeform_tags=None, @@ -1765,7 +1810,7 @@ def test_create_deployment_for_tei_byoc_embedding_model( ) mock_create.assert_called_with( - model_id=TestDataset.MODEL_ID, + model=TestDataset.MODEL_ID, compartment_id=TestDataset.USER_COMPARTMENT_ID, project_id=TestDataset.USER_PROJECT_ID, freeform_tags=None, @@ -1788,6 +1833,117 @@ def test_create_deployment_for_tei_byoc_embedding_model( ) assert actual_attributes == expected_result + @patch.object(AquaApp, "get_container_config_item") + @patch("ads.aqua.model.AquaModelApp.create") + @patch.object(AquaApp, "get_container_image") + @patch("ads.model.deployment.model_deployment.ModelDeployment.deploy") + @patch.object(AquaApp, "get_container_config") + def test_create_deployment_for_stack_model( + self, + mock_get_container_config, + mock_deploy, + mock_get_container_image, + mock_create, + mock_get_container_config_item, + ): + mock_get_container_config.return_value = ( + AquaContainerConfig.from_service_config( + service_containers=TestDataset.CONTAINER_LIST + ) + ) + + aqua_model_group = DataScienceModelGroup( + spec=TestDataset.aqua_deployment_stack_model + ) + mock_create.return_value = aqua_model_group + config_json = os.path.join( + self.curr_dir, "test_data/deployment/deployment_config.json" + ) + with open(config_json, "r") as _file: + config = json.load(_file) + + self.app.get_deployment_config = MagicMock( + return_value=AquaDeploymentConfig(**config) + ) + + freeform_tags = {"ftag1": "fvalue1", "ftag2": "fvalue2"} + defined_tags = {"dtag1": "dvalue1", "dtag2": "dvalue2"} + + mock_get_container_config_item.return_value = ( + TestDataset.INFERENCE_CONTAINER_CONFIG_ITEM + ) + + shapes = [] + + with open( + os.path.join( + self.curr_dir, + "test_data/deployment/aqua_deployment_shapes.json", + ), + "r", + ) as _file: + shapes = [ + ComputeShapeSummary(**item) for item in json.load(_file)["shapes"] + ] + + self.app.list_shapes = MagicMock(return_value=shapes) + + mock_get_container_image.return_value = TestDataset.DEPLOYMENT_IMAGE_NAME + aqua_deployment = os.path.join( + self.curr_dir, "test_data/deployment/aqua_create_deployment.yaml" + ) + model_deployment_obj = ModelDeployment.from_yaml(uri=aqua_deployment) + model_deployment_dsc_obj = copy.deepcopy(TestDataset.model_deployment_object[0]) + model_deployment_dsc_obj["lifecycle_state"] = "CREATING" + model_deployment_dsc_obj["defined_tags"] = defined_tags + model_deployment_dsc_obj["freeform_tags"].update(freeform_tags) + model_deployment_obj.dsc_model_deployment = ( + oci.data_science.models.ModelDeploymentSummary(**model_deployment_dsc_obj) + ) + model_deployment_obj.dsc_model_deployment.workflow_req_id = "workflow_req_id" + mock_deploy.return_value = model_deployment_obj + + ft_weights = [ + LoraModuleSpec( + model_id="ocid1.datasciencemodel.oc1..", + model_name="ft_model", + model_path="oci://test_bucket@test_namespace/models/ft-models/meta-llama-3b/ocid1.datasciencejob.oc1.iad.", + ) + ] + model_info = AquaMultiModelRef( + model_id="test_model_id", + model_name="test_model", + model_task="code_synthesis", + gpu_count=2, + artifact_location="oci://test_location", + fine_tune_weights=ft_weights, + ) + + result = self.app.create( + models=[model_info], + instance_shape=TestDataset.DEPLOYMENT_SHAPE_NAME, + display_name="model-deployment-name", + log_group_id="ocid1.loggroup.oc1..", + access_log_id="ocid1.log.oc1..", + predict_log_id="ocid1.log.oc1..", + freeform_tags=freeform_tags, + defined_tags=defined_tags, + deployment_type="STACKED", + ) + + mock_create.assert_called() + mock_get_container_image.assert_called() + mock_deploy.assert_called() + + expected_attributes = set(AquaDeployment.__annotations__.keys()) + actual_attributes = result.to_dict() + assert set(actual_attributes) == set(expected_attributes), "Attributes mismatch" + expected_result = copy.deepcopy(TestDataset.aqua_deployment_object) + expected_result["state"] = "CREATING" + expected_result["tags"].update(freeform_tags) + expected_result["tags"].update(defined_tags) + assert actual_attributes == expected_result + @patch.object(AquaApp, "get_container_config") @patch("ads.aqua.model.AquaModelApp.create_multi") @patch.object(AquaApp, "get_container_image") diff --git a/tests/unitary/with_extras/aqua/test_model.py b/tests/unitary/with_extras/aqua/test_model.py index 878f75a9c..5a678c8cc 100644 --- a/tests/unitary/with_extras/aqua/test_model.py +++ b/tests/unitary/with_extras/aqua/test_model.py @@ -418,7 +418,7 @@ def test_create_model(self, mock_from_id, mock_validate, mock_create): # will not copy service model self.app.create( - model_id="test_model_id", + model="test_model_id", project_id="test_project_id", compartment_id="test_compartment_id", ) @@ -433,7 +433,7 @@ def test_create_model(self, mock_from_id, mock_validate, mock_create): mock_model.freeform_tags.pop(Tags.BASE_MODEL_CUSTOM) # will copy service model model = self.app.create( - model_id="test_model_id", + model="test_model_id", project_id="test_project_id", compartment_id="test_compartment_id", )