diff --git a/agentplatform/_genai/model_garden.py b/agentplatform/_genai/model_garden.py index 511f958367..04fa04d69b 100644 --- a/agentplatform/_genai/model_garden.py +++ b/agentplatform/_genai/model_garden.py @@ -30,6 +30,43 @@ logger = logging.getLogger("agentplatform_genai.modelgarden") +def _DeployRequestParameters_to_vertex( + from_object: Union[dict[str, Any], object], + parent_object: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + to_object: dict[str, Any] = {} + if getv(from_object, ["destination"]) is not None: + setv(to_object, ["_url", "destination"], getv(from_object, ["destination"])) + + if getv(from_object, ["publisher_model_name"]) is not None: + setv( + to_object, + ["publisherModelName"], + getv(from_object, ["publisher_model_name"]), + ) + + if getv(from_object, ["hugging_face_model_id"]) is not None: + setv( + to_object, + ["huggingFaceModelId"], + getv(from_object, ["hugging_face_model_id"]), + ) + + if getv(from_object, ["custom_model"]) is not None: + setv(to_object, ["customModel"], getv(from_object, ["custom_model"])) + + if getv(from_object, ["model_config_val"]) is not None: + setv(to_object, ["modelConfig"], getv(from_object, ["model_config_val"])) + + if getv(from_object, ["endpoint_config"]) is not None: + setv(to_object, ["endpointConfig"], getv(from_object, ["endpoint_config"])) + + if getv(from_object, ["deploy_config"]) is not None: + setv(to_object, ["deployConfig"], getv(from_object, ["deploy_config"])) + + return to_object + + def _GetPublisherModelConfig_to_vertex( from_object: Union[dict[str, Any], object], parent_object: Optional[dict[str, Any]] = None, @@ -377,6 +414,90 @@ def _recommend_spec( self._api_client._verify_response(return_value) return return_value + def _deploy( + self, + *, + destination: str, + publisher_model_name: Optional[str] = None, + hugging_face_model_id: Optional[str] = None, + custom_model: Optional[types.DeployRequestCustomModelOrDict] = None, + model_config_val: Optional[types.DeployRequestModelConfigOrDict] = None, + endpoint_config: Optional[types.DeployRequestEndpointConfigOrDict] = None, + deploy_config: Optional[types.DeployRequestDeployConfigOrDict] = None, + config: Optional[types.DeployConfigOrDict] = None, + ) -> types.DeployModelOperation: + """ + Deploys a publisher or custom model (internal). + """ + + parameter_model = types._DeployRequestParameters( + destination=destination, + publisher_model_name=publisher_model_name, + hugging_face_model_id=hugging_face_model_id, + custom_model=custom_model, + model_config_val=model_config_val, + endpoint_config=endpoint_config, + deploy_config=deploy_config, + config=config, + ) + + request_url_dict: Optional[dict[str, str]] + if not self._api_client.vertexai: + raise ValueError( + "This method is only supported in Gemini Enterprise Agent Platform mode, not in Gemini Developer API mode." + ) + else: + request_dict = _DeployRequestParameters_to_vertex(parameter_model) + request_url_dict = request_dict.get("_url") + if request_url_dict: + path = "{destination}:deploy".format_map(request_url_dict) + else: + path = "{destination}:deploy" + + query_params = request_dict.get("_query") + if query_params: + path = f"{path}?{urlencode(query_params)}" + # TODO: remove the hack that pops config. + request_dict.pop("config", None) + + http_options: Optional[types.HttpOptions] = None + if ( + parameter_model.config is not None + and parameter_model.config.http_options is not None + ): + http_options = parameter_model.config.http_options + + request_dict = _common.convert_to_dict(request_dict) + request_dict = _common.encode_unserializable_types(request_dict) + + response = self._api_client.request("post", path, request_dict, http_options) + + response_dict = {} if not response.body else json.loads(response.body) + + return_value = types.DeployModelOperation._from_response( + response=response_dict, + kwargs=( + { + "config": { + "response_schema": getattr( + parameter_model.config, "response_schema", None + ), + "response_json_schema": getattr( + parameter_model.config, "response_json_schema", None + ), + "include_all_fields": getattr( + parameter_model.config, "include_all_fields", None + ), + } + } + if getattr(parameter_model, "config", None) + else {} + ), + ) + + self._api_client._verify_response(return_value) + return return_value + @staticmethod def _build_filter_str( model_filter: Optional[str], @@ -693,6 +814,84 @@ def _format_concise_deploy_options( blocks.append(header + "\n".join(lines)) return "\n\n".join(blocks) + @staticmethod + def _resolve_deploy_model_name(model: str) -> tuple[Optional[str], Optional[str]]: + """Returns the ``(publisher_model_name, hugging_face_model_id)`` pair for a deploy call. + + Exactly one element of the returned pair is set: HF model IDs (matched + by ``_is_hugging_face_model``) are sent as ``huggingFaceModelId`` and + lowercased for legacy parity; everything else is reconciled to the full + ``publishers/{pub}/models/{model}@{version}`` form. + """ + if ModelGarden._is_hugging_face_model(model): + return None, model.lower() + return ModelGarden._reconcile_model_name(model), None + + @staticmethod + def _build_container_spec( + config: types.DeployPublisherModelConfig, + ) -> Optional[types.ModelContainerSpec]: + """Returns a ``ModelContainerSpec`` when the user overrides the container, else None.""" + if not config.serving_container_image_uri: + return None + env = None + variables = config.container_variables + if variables: + env = [types.EnvVar(name=k, value=v) for k, v in variables.items()] + return types.ModelContainerSpec( + image_uri=config.serving_container_image_uri, + command=config.container_command, + args=config.container_args, + env=env, + ) + + @staticmethod + def _prepare_deploy_request( + config: types.DeployPublisherModelConfig, + ) -> tuple[ + types.DeployRequestModelConfig, + types.DeployRequestEndpointConfig, + types.DeployRequestDeployConfig, + ]: + """Translates ``DeployPublisherModelConfig`` to the three ``DeployRequest`` sub-messages.""" + model_config = types.DeployRequestModelConfig( + accept_eula=config.accept_eula, + model_display_name=config.model_display_name, + hugging_face_access_token=config.hugging_face_access_token, + container_spec=ModelGarden._build_container_spec(config), + ) + + endpoint_config = types.DeployRequestEndpointConfig( + endpoint_display_name=config.endpoint_display_name, + ) + disabled = config.dedicated_endpoint_disabled + if disabled is not None: + endpoint_config.dedicated_endpoint_enabled = not disabled + if config.enable_private_service_connect: + endpoint_config.private_service_connect_config = ( + types.PrivateServiceConnectConfig( + enable_private_service_connect=True, + project_allowlist=config.psc_project_allow_list, + ) + ) + + deploy_config = types.DeployRequestDeployConfig( + fast_tryout_enabled=config.fast_tryout_enabled, + ) + if config.machine_type or config.accelerator_type or config.accelerator_count: + deploy_config.dedicated_resources = types.DedicatedResources( + machine_spec=types.MachineSpec( + machine_type=config.machine_type, + accelerator_type=config.accelerator_type, + accelerator_count=config.accelerator_count, + ), + min_replica_count=config.min_replica_count, + max_replica_count=config.max_replica_count, + spot=config.spot, + ) + + return model_config, endpoint_config, deploy_config + def _list_all_publisher_models( self, api_config: types.ListPublisherModelsConfig, @@ -881,6 +1080,58 @@ def list_publisher_model_deploy_options( return options + def deploy_publisher_model( + self, + *, + model: str, + config: Optional[types.DeployPublisherModelConfigOrDict] = None, + ) -> types.DeployModelOperation: + """Deploys a Model Garden publisher model to a Vertex AI endpoint. + + Supports Google open models (e.g. ``'google/gemma3@gemma-3-12b-it'``), + partner publisher models (e.g. ``'ai21/jamba-large-1.6@001'``), and + Hugging Face model IDs (e.g. ``'meta-llama/Llama-3.3-70B-Instruct'``). + + Args: + model: The publisher model to deploy. Accepts the full resource name + ``'publishers/{publisher}/models/{model}@{version}'``, a simplified + ``'{publisher}/{model}@{version}'`` (or without the ``@{version}``), + or a Hugging Face model ID ``'{organization}/{model}'``. + config: Optional deployment configuration. Accepts a + ``DeployPublisherModelConfig`` instance or an equivalent dict. + + Returns: + A ``DeployModelOperation`` (long-running operation) whose response + carries the deployed ``endpoint`` and ``model`` resource names. + + Raises: + ValueError: If ``model`` is not a valid publisher model name. + """ + if config is None: + config = types.DeployPublisherModelConfig() + if isinstance(config, dict): + config = types.DeployPublisherModelConfig.model_validate(config) + + publisher_model_name, hugging_face_model_id = ( + ModelGarden._resolve_deploy_model_name(model) + ) + model_config, endpoint_config, deploy_config = ( + ModelGarden._prepare_deploy_request(config) + ) + destination = ( + f"projects/{self._api_client.project}/locations/" + f"{self._api_client.location}" + ) + + return self._deploy( + destination=destination, + publisher_model_name=publisher_model_name, + hugging_face_model_id=hugging_face_model_id, + model_config_val=model_config, + endpoint_config=endpoint_config, + deploy_config=deploy_config, + ) + @staticmethod def _extract_recommend_spec(spec) -> dict[str, Any]: """Extracts machine spec fields from a single recommend-spec entry. @@ -1256,6 +1507,92 @@ async def _recommend_spec( self._api_client._verify_response(return_value) return return_value + async def _deploy( + self, + *, + destination: str, + publisher_model_name: Optional[str] = None, + hugging_face_model_id: Optional[str] = None, + custom_model: Optional[types.DeployRequestCustomModelOrDict] = None, + model_config_val: Optional[types.DeployRequestModelConfigOrDict] = None, + endpoint_config: Optional[types.DeployRequestEndpointConfigOrDict] = None, + deploy_config: Optional[types.DeployRequestDeployConfigOrDict] = None, + config: Optional[types.DeployConfigOrDict] = None, + ) -> types.DeployModelOperation: + """ + Deploys a publisher or custom model (internal). + """ + + parameter_model = types._DeployRequestParameters( + destination=destination, + publisher_model_name=publisher_model_name, + hugging_face_model_id=hugging_face_model_id, + custom_model=custom_model, + model_config_val=model_config_val, + endpoint_config=endpoint_config, + deploy_config=deploy_config, + config=config, + ) + + request_url_dict: Optional[dict[str, str]] + if not self._api_client.vertexai: + raise ValueError( + "This method is only supported in Gemini Enterprise Agent Platform mode, not in Gemini Developer API mode." + ) + else: + request_dict = _DeployRequestParameters_to_vertex(parameter_model) + request_url_dict = request_dict.get("_url") + if request_url_dict: + path = "{destination}:deploy".format_map(request_url_dict) + else: + path = "{destination}:deploy" + + query_params = request_dict.get("_query") + if query_params: + path = f"{path}?{urlencode(query_params)}" + # TODO: remove the hack that pops config. + request_dict.pop("config", None) + + http_options: Optional[types.HttpOptions] = None + if ( + parameter_model.config is not None + and parameter_model.config.http_options is not None + ): + http_options = parameter_model.config.http_options + + request_dict = _common.convert_to_dict(request_dict) + request_dict = _common.encode_unserializable_types(request_dict) + + response = await self._api_client.async_request( + "post", path, request_dict, http_options + ) + + response_dict = {} if not response.body else json.loads(response.body) + + return_value = types.DeployModelOperation._from_response( + response=response_dict, + kwargs=( + { + "config": { + "response_schema": getattr( + parameter_model.config, "response_schema", None + ), + "response_json_schema": getattr( + parameter_model.config, "response_json_schema", None + ), + "include_all_fields": getattr( + parameter_model.config, "include_all_fields", None + ), + } + } + if getattr(parameter_model, "config", None) + else {} + ), + ) + + self._api_client._verify_response(return_value) + return return_value + async def _list_all_publisher_models( self, api_config: types.ListPublisherModelsConfig, @@ -1441,6 +1778,58 @@ async def list_publisher_model_deploy_options( return options + async def deploy_publisher_model( + self, + *, + model: str, + config: Optional[types.DeployPublisherModelConfigOrDict] = None, + ) -> types.DeployModelOperation: + """Deploys a Model Garden publisher model to a Vertex AI endpoint. + + Supports Google open models (e.g. ``'google/gemma3@gemma-3-12b-it'``), + partner publisher models (e.g. ``'ai21/jamba-large-1.6@001'``), and + Hugging Face model IDs (e.g. ``'meta-llama/Llama-3.3-70B-Instruct'``). + + Args: + model: The publisher model to deploy. Accepts the full resource name + ``'publishers/{publisher}/models/{model}@{version}'``, a simplified + ``'{publisher}/{model}@{version}'`` (or without the ``@{version}``), + or a Hugging Face model ID ``'{organization}/{model}'``. + config: Optional deployment configuration. Accepts a + ``DeployPublisherModelConfig`` instance or an equivalent dict. + + Returns: + A ``DeployModelOperation`` (long-running operation) whose response + carries the deployed ``endpoint`` and ``model`` resource names. + + Raises: + ValueError: If ``model`` is not a valid publisher model name. + """ + if config is None: + config = types.DeployPublisherModelConfig() + if isinstance(config, dict): + config = types.DeployPublisherModelConfig.model_validate(config) + + publisher_model_name, hugging_face_model_id = ( + ModelGarden._resolve_deploy_model_name(model) + ) + model_config, endpoint_config, deploy_config = ( + ModelGarden._prepare_deploy_request(config) + ) + destination = ( + f"projects/{self._api_client.project}/locations/" + f"{self._api_client.location}" + ) + + return await self._deploy( + destination=destination, + publisher_model_name=publisher_model_name, + hugging_face_model_id=hugging_face_model_id, + model_config_val=model_config, + endpoint_config=endpoint_config, + deploy_config=deploy_config, + ) + async def list_custom_model_deploy_options( self, src: str, diff --git a/agentplatform/_genai/types/__init__.py b/agentplatform/_genai/types/__init__.py index 46f7345e2e..8e3d5004e8 100644 --- a/agentplatform/_genai/types/__init__.py +++ b/agentplatform/_genai/types/__init__.py @@ -64,6 +64,7 @@ from .common import _DeleteSandboxEnvironmentSnapshotRequestParameters from .common import _DeleteSandboxEnvironmentTemplateRequestParameters from .common import _DeleteSkillRequestParameters +from .common import _DeployRequestParameters from .common import _EvaluateInstancesRequestParameters from .common import _ExecuteCodeAgentEngineSandboxRequestParameters from .common import _GenerateAgentEngineMemoriesRequestParameters @@ -485,9 +486,33 @@ from .common import DeleteSkillOperation from .common import DeleteSkillOperationDict from .common import DeleteSkillOperationOrDict +from .common import DeployConfig +from .common import DeployConfigDict +from .common import DeployConfigOrDict +from .common import DeployModelOperation +from .common import DeployModelOperationDict +from .common import DeployModelOperationOrDict from .common import DeployOption from .common import DeployOptionDict from .common import DeployOptionOrDict +from .common import DeployPublisherModelConfig +from .common import DeployPublisherModelConfigDict +from .common import DeployPublisherModelConfigOrDict +from .common import DeployRequestCustomModel +from .common import DeployRequestCustomModelDict +from .common import DeployRequestCustomModelOrDict +from .common import DeployRequestDeployConfig +from .common import DeployRequestDeployConfigDict +from .common import DeployRequestDeployConfigOrDict +from .common import DeployRequestEndpointConfig +from .common import DeployRequestEndpointConfigDict +from .common import DeployRequestEndpointConfigOrDict +from .common import DeployRequestModelConfig +from .common import DeployRequestModelConfigDict +from .common import DeployRequestModelConfigOrDict +from .common import DeployResponse +from .common import DeployResponseDict +from .common import DeployResponseOrDict from .common import DirectUploadSource from .common import DirectUploadSourceDict from .common import DirectUploadSourceOrDict @@ -1146,6 +1171,9 @@ from .common import PredictSchemata from .common import PredictSchemataDict from .common import PredictSchemataOrDict +from .common import PrivateServiceConnectConfig +from .common import PrivateServiceConnectConfigDict +from .common import PrivateServiceConnectConfigOrDict from .common import Probe from .common import ProbeDict from .common import ProbeExecAction @@ -1187,6 +1215,10 @@ from .common import PromptVersionRefDict from .common import PromptVersionRefOrDict from .common import Protocol +from .common import PSCAutomationConfig +from .common import PSCAutomationConfigDict +from .common import PSCAutomationConfigOrDict +from .common import PscAutomationState from .common import PscInterfaceConfig from .common import PscInterfaceConfigDict from .common import PscInterfaceConfigOrDict @@ -3463,6 +3495,33 @@ "RecommendSpecResponse", "RecommendSpecResponseDict", "RecommendSpecResponseOrDict", + "DeployConfig", + "DeployConfigDict", + "DeployConfigOrDict", + "DeployRequestCustomModel", + "DeployRequestCustomModelDict", + "DeployRequestCustomModelOrDict", + "DeployRequestModelConfig", + "DeployRequestModelConfigDict", + "DeployRequestModelConfigOrDict", + "PSCAutomationConfig", + "PSCAutomationConfigDict", + "PSCAutomationConfigOrDict", + "PrivateServiceConnectConfig", + "PrivateServiceConnectConfigDict", + "PrivateServiceConnectConfigOrDict", + "DeployRequestEndpointConfig", + "DeployRequestEndpointConfigDict", + "DeployRequestEndpointConfigOrDict", + "DeployRequestDeployConfig", + "DeployRequestDeployConfigDict", + "DeployRequestDeployConfigOrDict", + "DeployResponse", + "DeployResponseDict", + "DeployResponseOrDict", + "DeployModelOperation", + "DeployModelOperationDict", + "DeployModelOperationOrDict", "CreateRuntimeFeedbackEntryConfig", "CreateRuntimeFeedbackEntryConfigDict", "CreateRuntimeFeedbackEntryConfigOrDict", @@ -3613,6 +3672,9 @@ "ListCustomModelDeployOptionsConfig", "ListCustomModelDeployOptionsConfigDict", "ListCustomModelDeployOptionsConfigOrDict", + "DeployPublisherModelConfig", + "DeployPublisherModelConfigDict", + "DeployPublisherModelConfigOrDict", "DeployOption", "DeployOptionDict", "DeployOptionOrDict", @@ -3642,6 +3704,7 @@ "OpenSourceCategory", "VersionState", "QuotaState", + "PscAutomationState", "FeedbackType", "EvaluationItemType", "SamplingMethod", @@ -3804,6 +3867,7 @@ "_ListPublisherModelsRequestParameters", "_GetPublisherModelRequestParameters", "_RecommendSpecRequestParameters", + "_DeployRequestParameters", "_CreateRuntimeFeedbackEntryRequestParameters", "_DeleteRuntimeFeedbackEntryRequestParameters", "_GetRuntimeFeedbackRequestParameters", diff --git a/agentplatform/_genai/types/common.py b/agentplatform/_genai/types/common.py index 79c577c7fd..e3968ddc13 100644 --- a/agentplatform/_genai/types/common.py +++ b/agentplatform/_genai/types/common.py @@ -487,6 +487,17 @@ class QuotaState(_common.CaseInSensitiveEnum): """User does not have enough accelerator quota for the machine type.""" +class PscAutomationState(_common.CaseInSensitiveEnum): + """Output only. The state of the PSC service automation.""" + + PSC_AUTOMATION_STATE_UNSPECIFIED = "PSC_AUTOMATION_STATE_UNSPECIFIED" + """Should not be used.""" + PSC_AUTOMATION_STATE_SUCCESSFUL = "PSC_AUTOMATION_STATE_SUCCESSFUL" + """The PSC service automation is successful.""" + PSC_AUTOMATION_STATE_FAILED = "PSC_AUTOMATION_STATE_FAILED" + """The PSC service automation has failed.""" + + class FeedbackType(_common.CaseInSensitiveEnum): """The type of the feedback.""" @@ -23728,6 +23739,424 @@ class RecommendSpecResponseDict(TypedDict, total=False): RecommendSpecResponseOrDict = Union[RecommendSpecResponse, RecommendSpecResponseDict] +class DeployConfig(_common.BaseModel): + """RPC-level config for the private ``_deploy`` method.""" + + http_options: Optional[genai_types.HttpOptions] = Field( + default=None, description="""Used to override HTTP request options.""" + ) + + +class DeployConfigDict(TypedDict, total=False): + """RPC-level config for the private ``_deploy`` method.""" + + http_options: Optional[genai_types.HttpOptions] + """Used to override HTTP request options.""" + + +DeployConfigOrDict = Union[DeployConfig, DeployConfigDict] + + +class DeployRequestCustomModel(_common.BaseModel): + """The custom model to deploy from model weights in a Google Cloud Storage URI or Model Registry model.""" + + gcs_uri: Optional[str] = Field( + default=None, + description="""Immutable. The Google Cloud Storage URI of the custom model, storing weights and config files (which can be used to infer the base model).""", + ) + model_id: Optional[str] = Field( + default=None, + description="""Optional. Deprecated. Use ModelConfig.model_user_id instead.""", + ) + + +class DeployRequestCustomModelDict(TypedDict, total=False): + """The custom model to deploy from model weights in a Google Cloud Storage URI or Model Registry model.""" + + gcs_uri: Optional[str] + """Immutable. The Google Cloud Storage URI of the custom model, storing weights and config files (which can be used to infer the base model).""" + + model_id: Optional[str] + """Optional. Deprecated. Use ModelConfig.model_user_id instead.""" + + +DeployRequestCustomModelOrDict = Union[ + DeployRequestCustomModel, DeployRequestCustomModelDict +] + + +class DeployRequestModelConfig(_common.BaseModel): + """The model config to use for the deployment.""" + + accept_eula: Optional[bool] = Field( + default=None, + description="""Optional. Whether the user accepts the End User License Agreement (EULA) for the model.""", + ) + container_spec: Optional[ModelContainerSpec] = Field( + default=None, + description="""Optional. The specification of the container that is to be used when deploying. If not set, the default container spec will be used.""", + ) + hugging_face_access_token: Optional[str] = Field( + default=None, + description="""Optional. The Hugging Face read access token used to access the model artifacts of gated models.""", + ) + hugging_face_cache_enabled: Optional[bool] = Field( + default=None, + description="""Optional. If true, the model will deploy with a cached version instead of directly downloading the model artifacts from Hugging Face. This is suitable for VPC-SC users with limited internet access.""", + ) + model_display_name: Optional[str] = Field( + default=None, + description="""Optional. The user-specified display name of the uploaded model. If not set, a default name will be used.""", + ) + model_user_id: Optional[str] = Field( + default=None, + description="""Optional. The ID to use for the uploaded Model, which will become the final component of the model resource name. When not provided, Vertex AI will generate a value for this ID. When Model Registry model is provided, this field will be ignored. This value may be up to 63 characters, and valid characters are `[a-z0-9_-]`. The first character cannot be a number or hyphen.""", + ) + + +class DeployRequestModelConfigDict(TypedDict, total=False): + """The model config to use for the deployment.""" + + accept_eula: Optional[bool] + """Optional. Whether the user accepts the End User License Agreement (EULA) for the model.""" + + container_spec: Optional[ModelContainerSpecDict] + """Optional. The specification of the container that is to be used when deploying. If not set, the default container spec will be used.""" + + hugging_face_access_token: Optional[str] + """Optional. The Hugging Face read access token used to access the model artifacts of gated models.""" + + hugging_face_cache_enabled: Optional[bool] + """Optional. If true, the model will deploy with a cached version instead of directly downloading the model artifacts from Hugging Face. This is suitable for VPC-SC users with limited internet access.""" + + model_display_name: Optional[str] + """Optional. The user-specified display name of the uploaded model. If not set, a default name will be used.""" + + model_user_id: Optional[str] + """Optional. The ID to use for the uploaded Model, which will become the final component of the model resource name. When not provided, Vertex AI will generate a value for this ID. When Model Registry model is provided, this field will be ignored. This value may be up to 63 characters, and valid characters are `[a-z0-9_-]`. The first character cannot be a number or hyphen.""" + + +DeployRequestModelConfigOrDict = Union[ + DeployRequestModelConfig, DeployRequestModelConfigDict +] + + +class PSCAutomationConfig(_common.BaseModel): + """PSC config that is used to automatically create PSC endpoints in the user projects.""" + + error_message: Optional[str] = Field( + default=None, + description="""Output only. Error message if the PSC service automation failed.""", + ) + forwarding_rule: Optional[str] = Field( + default=None, + description="""Output only. Forwarding rule created by the PSC service automation.""", + ) + ip_address: Optional[str] = Field( + default=None, + description="""Output only. IP address rule created by the PSC service automation.""", + ) + network: Optional[str] = Field( + default=None, + description="""Required. The full name of the Google Compute Engine [network](https://cloud.google.com/compute/docs/networks-and-firewalls#networks). [Format](https://cloud.google.com/compute/docs/reference/rest/v1/networks/get): `projects/{project}/global/networks/{network}`.""", + ) + project_id: Optional[str] = Field( + default=None, + description="""Required. Project id used to create forwarding rule.""", + ) + state: Optional[PscAutomationState] = Field( + default=None, + description="""Output only. The state of the PSC service automation.""", + ) + + +class PSCAutomationConfigDict(TypedDict, total=False): + """PSC config that is used to automatically create PSC endpoints in the user projects.""" + + error_message: Optional[str] + """Output only. Error message if the PSC service automation failed.""" + + forwarding_rule: Optional[str] + """Output only. Forwarding rule created by the PSC service automation.""" + + ip_address: Optional[str] + """Output only. IP address rule created by the PSC service automation.""" + + network: Optional[str] + """Required. The full name of the Google Compute Engine [network](https://cloud.google.com/compute/docs/networks-and-firewalls#networks). [Format](https://cloud.google.com/compute/docs/reference/rest/v1/networks/get): `projects/{project}/global/networks/{network}`.""" + + project_id: Optional[str] + """Required. Project id used to create forwarding rule.""" + + state: Optional[PscAutomationState] + """Output only. The state of the PSC service automation.""" + + +PSCAutomationConfigOrDict = Union[PSCAutomationConfig, PSCAutomationConfigDict] + + +class PrivateServiceConnectConfig(_common.BaseModel): + """Represents configuration for private service connect.""" + + enable_private_service_connect: Optional[bool] = Field( + default=None, + description="""Required. If true, expose the IndexEndpoint via private service connect.""", + ) + enable_secure_private_service_connect: Optional[bool] = Field( + default=None, + description="""Optional. If set to true, enable secure private service connect with IAM authorization. Otherwise, private service connect will be done without authorization. Note latency will be slightly increased if authorization is enabled.""", + ) + project_allowlist: Optional[list[str]] = Field( + default=None, + description="""A list of Projects from which the forwarding rule will target the service attachment.""", + ) + psc_automation_configs: Optional[list[PSCAutomationConfig]] = Field( + default=None, + description="""Optional. List of projects and networks where the PSC endpoints will be created. This field is used by Online Inference(Prediction) only.""", + ) + service_attachment: Optional[str] = Field( + default=None, + description="""Output only. The name of the generated service attachment resource. This is only populated if the endpoint is deployed with PrivateServiceConnect.""", + ) + + +class PrivateServiceConnectConfigDict(TypedDict, total=False): + """Represents configuration for private service connect.""" + + enable_private_service_connect: Optional[bool] + """Required. If true, expose the IndexEndpoint via private service connect.""" + + enable_secure_private_service_connect: Optional[bool] + """Optional. If set to true, enable secure private service connect with IAM authorization. Otherwise, private service connect will be done without authorization. Note latency will be slightly increased if authorization is enabled.""" + + project_allowlist: Optional[list[str]] + """A list of Projects from which the forwarding rule will target the service attachment.""" + + psc_automation_configs: Optional[list[PSCAutomationConfigDict]] + """Optional. List of projects and networks where the PSC endpoints will be created. This field is used by Online Inference(Prediction) only.""" + + service_attachment: Optional[str] + """Output only. The name of the generated service attachment resource. This is only populated if the endpoint is deployed with PrivateServiceConnect.""" + + +PrivateServiceConnectConfigOrDict = Union[ + PrivateServiceConnectConfig, PrivateServiceConnectConfigDict +] + + +class DeployRequestEndpointConfig(_common.BaseModel): + """The endpoint config to use for the deployment.""" + + dedicated_endpoint_disabled: Optional[bool] = Field( + default=None, + description="""Optional. By default, if dedicated endpoint is enabled and private service connect config is not set, the endpoint will be exposed through a dedicated DNS [Endpoint.dedicated_endpoint_dns]. If private service connect config is set, the endpoint will be exposed through private service connect. Your request to the dedicated DNS will be isolated from other users' traffic and will have better performance and reliability. Note: Once you enabled dedicated endpoint, you won't be able to send request to the shared DNS {region}-aiplatform.googleapis.com. The limitations will be removed soon. If this field is set to true, the dedicated endpoint will be disabled and the deployed model will be exposed through the shared DNS {region}-aiplatform.googleapis.com.""", + ) + dedicated_endpoint_enabled: Optional[bool] = Field( + default=None, + description="""Optional. Deprecated. Use dedicated_endpoint_disabled instead. If true, the endpoint will be exposed through a dedicated DNS [Endpoint.dedicated_endpoint_dns]. Your request to the dedicated DNS will be isolated from other users' traffic and will have better performance and reliability. Note: Once you enabled dedicated endpoint, you won't be able to send request to the shared DNS {region}-aiplatform.googleapis.com. The limitations will be removed soon.""", + ) + endpoint_display_name: Optional[str] = Field( + default=None, + description="""Optional. The user-specified display name of the endpoint. If not set, a default name will be used.""", + ) + endpoint_user_id: Optional[str] = Field( + default=None, + description="""Optional. Immutable. The ID to use for endpoint, which will become the final component of the endpoint resource name. If not provided, Vertex AI will generate a value for this ID. If the first character is a letter, this value may be up to 63 characters, and valid characters are `[a-z0-9-]`. The last character must be a letter or number. If the first character is a number, this value may be up to 9 characters, and valid characters are `[0-9]` with no leading zeros. When using HTTP/JSON, this field is populated based on a query string argument, such as `?endpoint_id=12345`. This is the fallback for fields that are not included in either the URI or the body.""", + ) + labels: Optional[dict[str, str]] = Field( + default=None, + description="""Optional. The labels with user-defined metadata to organize your Endpoints. Label keys and values can be no longer than 64 characters (Unicode codepoints), can only contain lowercase letters, numeric characters, underscores and dashes. International characters are allowed. See https://goo.gl/xmQnxf for more information and examples of labels.""", + ) + private_service_connect_config: Optional[PrivateServiceConnectConfig] = Field( + default=None, + description="""Optional. Configuration for private service connect. If set, the endpoint will be exposed through private service connect.""", + ) + + +class DeployRequestEndpointConfigDict(TypedDict, total=False): + """The endpoint config to use for the deployment.""" + + dedicated_endpoint_disabled: Optional[bool] + """Optional. By default, if dedicated endpoint is enabled and private service connect config is not set, the endpoint will be exposed through a dedicated DNS [Endpoint.dedicated_endpoint_dns]. If private service connect config is set, the endpoint will be exposed through private service connect. Your request to the dedicated DNS will be isolated from other users' traffic and will have better performance and reliability. Note: Once you enabled dedicated endpoint, you won't be able to send request to the shared DNS {region}-aiplatform.googleapis.com. The limitations will be removed soon. If this field is set to true, the dedicated endpoint will be disabled and the deployed model will be exposed through the shared DNS {region}-aiplatform.googleapis.com.""" + + dedicated_endpoint_enabled: Optional[bool] + """Optional. Deprecated. Use dedicated_endpoint_disabled instead. If true, the endpoint will be exposed through a dedicated DNS [Endpoint.dedicated_endpoint_dns]. Your request to the dedicated DNS will be isolated from other users' traffic and will have better performance and reliability. Note: Once you enabled dedicated endpoint, you won't be able to send request to the shared DNS {region}-aiplatform.googleapis.com. The limitations will be removed soon.""" + + endpoint_display_name: Optional[str] + """Optional. The user-specified display name of the endpoint. If not set, a default name will be used.""" + + endpoint_user_id: Optional[str] + """Optional. Immutable. The ID to use for endpoint, which will become the final component of the endpoint resource name. If not provided, Vertex AI will generate a value for this ID. If the first character is a letter, this value may be up to 63 characters, and valid characters are `[a-z0-9-]`. The last character must be a letter or number. If the first character is a number, this value may be up to 9 characters, and valid characters are `[0-9]` with no leading zeros. When using HTTP/JSON, this field is populated based on a query string argument, such as `?endpoint_id=12345`. This is the fallback for fields that are not included in either the URI or the body.""" + + labels: Optional[dict[str, str]] + """Optional. The labels with user-defined metadata to organize your Endpoints. Label keys and values can be no longer than 64 characters (Unicode codepoints), can only contain lowercase letters, numeric characters, underscores and dashes. International characters are allowed. See https://goo.gl/xmQnxf for more information and examples of labels.""" + + private_service_connect_config: Optional[PrivateServiceConnectConfigDict] + """Optional. Configuration for private service connect. If set, the endpoint will be exposed through private service connect.""" + + +DeployRequestEndpointConfigOrDict = Union[ + DeployRequestEndpointConfig, DeployRequestEndpointConfigDict +] + + +class DeployRequestDeployConfig(_common.BaseModel): + """The deploy config to use for the deployment.""" + + dedicated_resources: Optional[DedicatedResources] = Field( + default=None, + description="""Optional. The dedicated resources to use for the endpoint. If not set, the default resources will be used.""", + ) + fast_tryout_enabled: Optional[bool] = Field( + default=None, + description="""Optional. If true, enable the QMT fast tryout feature for this model if possible.""", + ) + system_labels: Optional[dict[str, str]] = Field( + default=None, + description="""Optional. System labels for Model Garden deployments. These labels are managed by Google and for tracking purposes only.""", + ) + + +class DeployRequestDeployConfigDict(TypedDict, total=False): + """The deploy config to use for the deployment.""" + + dedicated_resources: Optional[DedicatedResourcesDict] + """Optional. The dedicated resources to use for the endpoint. If not set, the default resources will be used.""" + + fast_tryout_enabled: Optional[bool] + """Optional. If true, enable the QMT fast tryout feature for this model if possible.""" + + system_labels: Optional[dict[str, str]] + """Optional. System labels for Model Garden deployments. These labels are managed by Google and for tracking purposes only.""" + + +DeployRequestDeployConfigOrDict = Union[ + DeployRequestDeployConfig, DeployRequestDeployConfigDict +] + + +class _DeployRequestParameters(_common.BaseModel): + """Parameters for the private ``_deploy`` method.""" + + destination: Optional[str] = Field(default=None, description="""""") + publisher_model_name: Optional[str] = Field(default=None, description="""""") + hugging_face_model_id: Optional[str] = Field(default=None, description="""""") + custom_model: Optional[DeployRequestCustomModel] = Field( + default=None, description="""""" + ) + model_config_val: Optional[DeployRequestModelConfig] = Field( + default=None, description="""""" + ) + endpoint_config: Optional[DeployRequestEndpointConfig] = Field( + default=None, description="""""" + ) + deploy_config: Optional[DeployRequestDeployConfig] = Field( + default=None, description="""""" + ) + config: Optional[DeployConfig] = Field(default=None, description="""""") + + +class _DeployRequestParametersDict(TypedDict, total=False): + """Parameters for the private ``_deploy`` method.""" + + destination: Optional[str] + """""" + + publisher_model_name: Optional[str] + """""" + + hugging_face_model_id: Optional[str] + """""" + + custom_model: Optional[DeployRequestCustomModelDict] + """""" + + model_config_val: Optional[DeployRequestModelConfigDict] + """""" + + endpoint_config: Optional[DeployRequestEndpointConfigDict] + """""" + + deploy_config: Optional[DeployRequestDeployConfigDict] + """""" + + config: Optional[DeployConfigDict] + """""" + + +_DeployRequestParametersOrDict = Union[ + _DeployRequestParameters, _DeployRequestParametersDict +] + + +class DeployResponse(_common.BaseModel): + """Response for a deployment operation.""" + + endpoint: Optional[str] = Field( + default=None, description="""Resource name of the deployed endpoint.""" + ) + model: Optional[str] = Field( + default=None, description="""Resource name of the deployed model.""" + ) + + +class DeployResponseDict(TypedDict, total=False): + """Response for a deployment operation.""" + + endpoint: Optional[str] + """Resource name of the deployed endpoint.""" + + model: Optional[str] + """Resource name of the deployed model.""" + + +DeployResponseOrDict = Union[DeployResponse, DeployResponseDict] + + +class DeployModelOperation(_common.BaseModel): + """Long-running operation returned by a deployment call.""" + + name: Optional[str] = Field( + default=None, + description="""The server-assigned name, which is only unique within the same service that originally returns it. If you use the default HTTP mapping, the `name` should be a resource name ending with `operations/{unique_id}`.""", + ) + metadata: Optional[dict[str, Any]] = Field( + default=None, + description="""Service-specific metadata associated with the operation. It typically contains progress information and common metadata such as create time. Some services might not provide such metadata. Any method that returns a long-running operation should document the metadata type, if any.""", + ) + done: Optional[bool] = Field( + default=None, + description="""If the value is `false`, it means the operation is still in progress. If `true`, the operation is completed, and either `error` or `response` is available.""", + ) + error: Optional[dict[str, Any]] = Field( + default=None, + description="""The error result of the operation in case of failure or cancellation.""", + ) + response: Optional[DeployResponse] = Field(default=None, description="""""") + + +class DeployModelOperationDict(TypedDict, total=False): + """Long-running operation returned by a deployment call.""" + + name: Optional[str] + """The server-assigned name, which is only unique within the same service that originally returns it. If you use the default HTTP mapping, the `name` should be a resource name ending with `operations/{unique_id}`.""" + + metadata: Optional[dict[str, Any]] + """Service-specific metadata associated with the operation. It typically contains progress information and common metadata such as create time. Some services might not provide such metadata. Any method that returns a long-running operation should document the metadata type, if any.""" + + done: Optional[bool] + """If the value is `false`, it means the operation is still in progress. If `true`, the operation is completed, and either `error` or `response` is available.""" + + error: Optional[dict[str, Any]] + """The error result of the operation in case of failure or cancellation.""" + + response: Optional[DeployResponseDict] + """""" + + +DeployModelOperationOrDict = Union[DeployModelOperation, DeployModelOperationDict] + + class CreateRuntimeFeedbackEntryConfig(_common.BaseModel): """Config for creating a Feedback Entry.""" @@ -26770,6 +27199,155 @@ class ListCustomModelDeployOptionsConfigDict(TypedDict, total=False): ] +class DeployPublisherModelConfig(_common.BaseModel): + """Config for ``deploy_publisher_model``. + + Superset of options that apply to Google open, partner and Hugging Face + publisher models. Only fields relevant to the target model are honored; + the backend rejects unsupported fields with a clear error. + """ + + accept_eula: Optional[bool] = Field( + default=None, + description="""Whether to accept the model's End User License Agreement.""", + ) + hugging_face_access_token: Optional[str] = Field( + default=None, + description="""Hugging Face access token for gated HF models. See + https://huggingface.co/docs/hub/en/security-tokens.""", + ) + machine_type: Optional[str] = Field( + default=None, + description="""Machine type (e.g. ``'g2-standard-48'``). Leave unset for + automatic resources.""", + ) + min_replica_count: Optional[int] = Field( + default=1, description="""Minimum number of replicas.""" + ) + max_replica_count: Optional[int] = Field( + default=1, description="""Maximum number of replicas.""" + ) + accelerator_type: Optional[str] = Field( + default=None, description="""Accelerator type (e.g. ``'NVIDIA_L4'``).""" + ) + accelerator_count: Optional[int] = Field( + default=None, description="""Number of accelerators per replica.""" + ) + spot: Optional[bool] = Field(default=None, description="""Schedule on Spot VMs.""") + dedicated_endpoint_disabled: Optional[bool] = Field( + default=None, + description="""Set True to serve predictions via the shared endpoint DNS + instead of the dedicated endpoint DNS (default).""", + ) + fast_tryout_enabled: Optional[bool] = Field( + default=None, + description="""Use the fast-tryout deployment path (experimentation only, not + production). Only supported for select models and machine types.""", + ) + endpoint_display_name: Optional[str] = Field( + default=None, description="""Display name for the endpoint.""" + ) + model_display_name: Optional[str] = Field( + default=None, description="""Display name for the deployed model.""" + ) + serving_container_image_uri: Optional[str] = Field( + default=None, + description="""Custom serving container image URI overriding the model's + default container.""", + ) + container_command: Optional[list[str]] = Field( + default=None, description="""Serving container ENTRYPOINT override.""" + ) + container_args: Optional[list[str]] = Field( + default=None, description="""Serving container CMD override.""" + ) + container_variables: Optional[dict[str, str]] = Field( + default=None, description="""Environment variables for the serving container.""" + ) + enable_private_service_connect: Optional[bool] = Field( + default=None, description="""Enable Private Service Connect for the endpoint.""" + ) + psc_project_allow_list: Optional[list[str]] = Field( + default=None, + description="""Projects allowed to access the endpoint over Private Service + Connect. Only honored when ``enable_private_service_connect`` is True.""", + ) + + +class DeployPublisherModelConfigDict(TypedDict, total=False): + """Config for ``deploy_publisher_model``. + + Superset of options that apply to Google open, partner and Hugging Face + publisher models. Only fields relevant to the target model are honored; + the backend rejects unsupported fields with a clear error. + """ + + accept_eula: Optional[bool] + """Whether to accept the model's End User License Agreement.""" + + hugging_face_access_token: Optional[str] + """Hugging Face access token for gated HF models. See + https://huggingface.co/docs/hub/en/security-tokens.""" + + machine_type: Optional[str] + """Machine type (e.g. ``'g2-standard-48'``). Leave unset for + automatic resources.""" + + min_replica_count: Optional[int] + """Minimum number of replicas.""" + + max_replica_count: Optional[int] + """Maximum number of replicas.""" + + accelerator_type: Optional[str] + """Accelerator type (e.g. ``'NVIDIA_L4'``).""" + + accelerator_count: Optional[int] + """Number of accelerators per replica.""" + + spot: Optional[bool] + """Schedule on Spot VMs.""" + + dedicated_endpoint_disabled: Optional[bool] + """Set True to serve predictions via the shared endpoint DNS + instead of the dedicated endpoint DNS (default).""" + + fast_tryout_enabled: Optional[bool] + """Use the fast-tryout deployment path (experimentation only, not + production). Only supported for select models and machine types.""" + + endpoint_display_name: Optional[str] + """Display name for the endpoint.""" + + model_display_name: Optional[str] + """Display name for the deployed model.""" + + serving_container_image_uri: Optional[str] + """Custom serving container image URI overriding the model's + default container.""" + + container_command: Optional[list[str]] + """Serving container ENTRYPOINT override.""" + + container_args: Optional[list[str]] + """Serving container CMD override.""" + + container_variables: Optional[dict[str, str]] + """Environment variables for the serving container.""" + + enable_private_service_connect: Optional[bool] + """Enable Private Service Connect for the endpoint.""" + + psc_project_allow_list: Optional[list[str]] + """Projects allowed to access the endpoint over Private Service + Connect. Only honored when ``enable_private_service_connect`` is True.""" + + +DeployPublisherModelConfigOrDict = Union[ + DeployPublisherModelConfig, DeployPublisherModelConfigDict +] + + class DeployOption(_common.BaseModel): """A verified deploy option for a model.""" diff --git a/tests/unit/agentplatform/genai/replays/test_genai_model_garden.py b/tests/unit/agentplatform/genai/replays/test_genai_model_garden.py index 828976c517..bf9a4db227 100644 --- a/tests/unit/agentplatform/genai/replays/test_genai_model_garden.py +++ b/tests/unit/agentplatform/genai/replays/test_genai_model_garden.py @@ -34,72 +34,72 @@ def test_list_deployable_models(client): def test_list_models(client): - """Tests listing all baseline models in Model Garden.""" - models = client.model_garden.list_models( + """Tests listing all baseline models in Model Garden.""" + models = client.model_garden.list_models( config=types.ListModelGardenModelsConfig( include_hugging_face_models=False, model_filter="timesfm", ) ) - assert len(models) > 0 - assert isinstance(models[0], str) - assert "timesfm" in models[0].lower() + assert len(models) > 0 + assert isinstance(models[0], str) + assert "timesfm" in models[0].lower() def test_list_publisher_model_deploy_options(client): - """Tests listing the verified deploy options for an open model.""" - options = client.model_garden.list_publisher_model_deploy_options( - model="google/gemma3@gemma-3-12b-it" - ) - assert len(options) > 0 - assert isinstance(options[0], types.DeployOption) - # Every verified deploy option exposes a serving container image. - assert options[0].serving_container_image_uri + """Tests listing the verified deploy options for an open model.""" + options = client.model_garden.list_publisher_model_deploy_options( + model="google/gemma3@gemma-3-12b-it" + ) + assert len(options) > 0 + assert isinstance(options[0], types.DeployOption) + # Every verified deploy option exposes a serving container image. + assert options[0].serving_container_image_uri def test_list_publisher_model_deploy_options_with_filter(client): - """Tests filtering an open model's deploy options by accelerator type.""" - options = client.model_garden.list_publisher_model_deploy_options( - model="google/gemma3@gemma-3-12b-it", - config=types.ListPublisherModelDeployOptionsConfig( - accelerator_type_filter="NVIDIA" - ), - ) - assert len(options) > 0 - for option in options: - assert "NVIDIA" in (option.accelerator_type or "") + """Tests filtering an open model's deploy options by accelerator type.""" + options = client.model_garden.list_publisher_model_deploy_options( + model="google/gemma3@gemma-3-12b-it", + config=types.ListPublisherModelDeployOptionsConfig( + accelerator_type_filter="NVIDIA" + ), + ) + assert len(options) > 0 + for option in options: + assert "NVIDIA" in (option.accelerator_type or "") def test_list_publisher_model_deploy_options_concise(client): - """Tests the concise (human-readable string) output for an open model.""" - options = client.model_garden.list_publisher_model_deploy_options( - model="google/gemma3@gemma-3-12b-it", - config=types.ListPublisherModelDeployOptionsConfig(concise=True), - ) - assert isinstance(options, str) - assert "[Option 1" in options + """Tests the concise (human-readable string) output for an open model.""" + options = client.model_garden.list_publisher_model_deploy_options( + model="google/gemma3@gemma-3-12b-it", + config=types.ListPublisherModelDeployOptionsConfig(concise=True), + ) + assert isinstance(options, str) + assert "[Option 1" in options def test_list_publisher_model_deploy_options_hugging_face(client): - """Tests deploy options for a Hugging Face model. + """Tests deploy options for a Hugging Face model. Exercises the distinct GetPublisherModel request path where is_hugging_face_model=True is sent. """ - options = client.model_garden.list_publisher_model_deploy_options( - model="codellama/codellama-7b-hf" - ) - assert len(options) > 0 - assert isinstance(options[0], types.DeployOption) - assert options[0].serving_container_image_uri + options = client.model_garden.list_publisher_model_deploy_options( + model="codellama/codellama-7b-hf" + ) + assert len(options) > 0 + assert isinstance(options[0], types.DeployOption) + assert options[0].serving_container_image_uri def test_list_publisher_model_deploy_options_no_deploy_support(client): - """Tests a model with no verified deployment config raises ValueError.""" - with pytest.raises(ValueError, match="does not support deployment"): - client.model_garden.list_publisher_model_deploy_options( - model="google/gemini-embedding-001@default" - ) + """Tests a model with no verified deployment config raises ValueError.""" + with pytest.raises(ValueError, match="does not support deployment"): + client.model_garden.list_publisher_model_deploy_options( + model="google/gemini-embedding-001@default" + ) # A GCS folder holding a custom model (config.json + a weights file) that @@ -108,57 +108,101 @@ def test_list_publisher_model_deploy_options_no_deploy_support(client): def test_list_custom_model_deploy_options(client): - """Default config: machine availability + user-quota filter. - - Exercises the RecommendSpec backend (distinct from the GetPublisherModel - path used by publisher models) and the human-readable string return type. - With ``check_machine_availability`` defaulting to True, the response is - the per-region ``recommendations`` list, so each option carries a - ``region``. - """ - options = client.model_garden.list_custom_model_deploy_options( - src=_CUSTOM_MODEL_SRC, - ) - assert isinstance(options, str) - assert "[Option 1]" in options - assert "region=" in options + """Default config: machine availability + user-quota filter. + + Exercises the RecommendSpec backend (distinct from the GetPublisherModel + path used by publisher models) and the human-readable string return type. + With ``check_machine_availability`` defaulting to True, the response is + the per-region ``recommendations`` list, so each option carries a + ``region``. + """ + options = client.model_garden.list_custom_model_deploy_options( + src=_CUSTOM_MODEL_SRC, + ) + assert isinstance(options, str) + assert "[Option 1]" in options + assert "region=" in options def test_list_custom_model_deploy_options_check_machine_availability_false( client, ): - """check_machine_availability=False -> RecommendSpec 'specs' path (no region field).""" - options = client.model_garden.list_custom_model_deploy_options( - src=_CUSTOM_MODEL_SRC, - config=types.ListCustomModelDeployOptionsConfig( - check_machine_availability=False - ), - ) - assert isinstance(options, str) - assert "[Option 1]" in options - assert "region=" not in options + """check_machine_availability=False -> RecommendSpec 'specs' path (no region field).""" + options = client.model_garden.list_custom_model_deploy_options( + src=_CUSTOM_MODEL_SRC, + config=types.ListCustomModelDeployOptionsConfig( + check_machine_availability=False + ), + ) + assert isinstance(options, str) + assert "[Option 1]" in options + assert "region=" not in options def test_list_custom_model_deploy_options_no_user_quota_filter(client): - """filter_by_user_quota=False -> recommendations returned without quota filtering.""" - options = client.model_garden.list_custom_model_deploy_options( - src=_CUSTOM_MODEL_SRC, - config=types.ListCustomModelDeployOptionsConfig(filter_by_user_quota=False), - ) - assert isinstance(options, str) - assert "[Option 1]" in options - assert "region=" in options + """filter_by_user_quota=False -> recommendations returned without quota filtering.""" + options = client.model_garden.list_custom_model_deploy_options( + src=_CUSTOM_MODEL_SRC, + config=types.ListCustomModelDeployOptionsConfig(filter_by_user_quota=False), + ) + assert isinstance(options, str) + assert "[Option 1]" in options + assert "region=" in options def test_list_custom_model_deploy_options_dict_config(client): - """The config may be passed as a plain dict instead of the typed config.""" - options = client.model_garden.list_custom_model_deploy_options( - src=_CUSTOM_MODEL_SRC, - config={"check_machine_availability": False}, - ) - assert isinstance(options, str) - assert "[Option 1]" in options - assert "region=" not in options + """The config may be passed as a plain dict instead of the typed config.""" + options = client.model_garden.list_custom_model_deploy_options( + src=_CUSTOM_MODEL_SRC, + config={"check_machine_availability": False}, + ) + assert isinstance(options, str) + assert "[Option 1]" in options + assert "region=" not in options + + +def test_deploy_publisher_model_open(client): + """Deploy an open model via publisher_model_name (fast_tryout keeps it cheap).""" + operation = client.model_garden.deploy_publisher_model( + model="google/gemma2@gemma-2-2b-it", + config=types.DeployPublisherModelConfig( + accept_eula=True, + fast_tryout_enabled=True, + ), + ) + assert isinstance(operation, types.DeployModelOperation) + assert operation.name + + +def test_deploy_publisher_model_open_with_machine_config(client): + """Deploy with explicit machine + accelerator (DedicatedResources path).""" + operation = client.model_garden.deploy_publisher_model( + model="google/gemma2@gemma-2-2b-it", + config=types.DeployPublisherModelConfig( + accept_eula=True, + machine_type="g2-standard-12", + accelerator_type="NVIDIA_L4", + accelerator_count=1, + ), + ) + assert isinstance(operation, types.DeployModelOperation) + assert operation.name + + +def test_deploy_publisher_model_hugging_face(client): + """Deploy an HF model via hugging_face_model_id (wire path branches on model name).""" + operation = client.model_garden.deploy_publisher_model( + model="codellama/codellama-7b-hf", + config=types.DeployPublisherModelConfig(accept_eula=True), + ) + assert isinstance(operation, types.DeployModelOperation) + assert operation.name + + +def test_deploy_publisher_model_invalid_name_raises(client): + """Invalid model name is rejected client-side; no RPC needed.""" + with pytest.raises(ValueError, match="not a valid publisher model name"): + client.model_garden.deploy_publisher_model(model="not-a-valid-name") pytestmark = pytest_helper.setup( @@ -199,24 +243,38 @@ async def test_list_models_async(client): @pytest.mark.asyncio async def test_list_publisher_model_deploy_options_async(client): - """Tests listing the deploy options for an open model asynchronously.""" - options = await client.aio.model_garden.list_publisher_model_deploy_options( - model="google/gemma3@gemma-3-12b-it" - ) - assert len(options) > 0 - assert isinstance(options[0], types.DeployOption) - assert options[0].serving_container_image_uri + """Tests listing the deploy options for an open model asynchronously.""" + options = await client.aio.model_garden.list_publisher_model_deploy_options( + model="google/gemma3@gemma-3-12b-it" + ) + assert len(options) > 0 + assert isinstance(options[0], types.DeployOption) + assert options[0].serving_container_image_uri @pytest.mark.asyncio async def test_list_custom_model_deploy_options_async(client): - """Tests listing the recommended deploy options for a custom model async.""" - options = await client.aio.model_garden.list_custom_model_deploy_options( - src=_CUSTOM_MODEL_SRC, - config=types.ListCustomModelDeployOptionsConfig( - filter_by_user_quota=False, - ), - ) - assert isinstance(options, str) - assert "[Option 1]" in options - assert "region=" in options + """Tests listing the recommended deploy options for a custom model async.""" + options = await client.aio.model_garden.list_custom_model_deploy_options( + src=_CUSTOM_MODEL_SRC, + config=types.ListCustomModelDeployOptionsConfig( + filter_by_user_quota=False, + ), + ) + assert isinstance(options, str) + assert "[Option 1]" in options + assert "region=" in options + + +@pytest.mark.asyncio +async def test_deploy_publisher_model_async(client): + """Async deploy path also returns a valid DeployModelOperation.""" + operation = await client.aio.model_garden.deploy_publisher_model( + model="google/gemma2@gemma-2-2b-it", + config=types.DeployPublisherModelConfig( + accept_eula=True, + fast_tryout_enabled=True, + ), + ) + assert isinstance(operation, types.DeployModelOperation) + assert operation.name diff --git a/tests/unit/agentplatform/genai/test_genai_model_garden.py b/tests/unit/agentplatform/genai/test_genai_model_garden.py index bd39dd7e82..c65a7dbd61 100644 --- a/tests/unit/agentplatform/genai/test_genai_model_garden.py +++ b/tests/unit/agentplatform/genai/test_genai_model_garden.py @@ -232,9 +232,7 @@ def test_list_models_excludes_hf_via_server_filter(mock_client): mock_client, "_list_publisher_models", return_value=dummy_response ) as mock_list: models = mock_client.list_models( - config=types.ListModelGardenModelsConfig( - include_hugging_face_models=False - ) + config=types.ListModelGardenModelsConfig(include_hugging_face_models=False) ) api_config = mock_list.call_args.kwargs.get("config") @@ -260,9 +258,7 @@ def test_list_models_with_hf(mock_client): mock_client, "_list_publisher_models", return_value=dummy_response ): models = mock_client.list_models( - config=types.ListModelGardenModelsConfig( - include_hugging_face_models=True - ) + config=types.ListModelGardenModelsConfig(include_hugging_face_models=True) ) assert len(models) == 2 @@ -290,9 +286,7 @@ def test_list_models_includes_non_deployable(mock_client): mock_client, "_list_publisher_models", return_value=dummy_response ): models = mock_client.list_models( - config=types.ListModelGardenModelsConfig( - include_hugging_face_models=False - ) + config=types.ListModelGardenModelsConfig(include_hugging_face_models=False) ) assert len(models) == 2 @@ -416,13 +410,15 @@ def test_build_filter_str_with_model_filter(): def test_build_filter_str_escapes_special_chars(): - """Tests that special regex characters in model_filter are escaped.""" - build_filter = model_garden.ModelGarden._build_filter_str - result = build_filter( - model_filter="model.v2+", include_hugging_face_models=False, deployable_only=False + """Tests that special regex characters in model_filter are escaped.""" + build_filter = model_garden.ModelGarden._build_filter_str + result = build_filter( + model_filter="model.v2+", + include_hugging_face_models=False, + deployable_only=False, ) - # re.escape turns '.' into '\\.' and '+' into '\\+' - assert r"model\.v2\+" in result + # re.escape turns '.' into '\\.' and '+' into '\\+' + assert r"model\.v2\+" in result # ---- list_publisher_model_deploy_options tests ---- @@ -449,11 +445,9 @@ def _make_deploy_option( ) -def _make_model_with_deploy_options( - options, name="publishers/google/models/gemma-2b" -): - """Builds a PublisherModel exposing the given deploy options.""" - return types.PublisherModel( +def _make_model_with_deploy_options(options, name="publishers/google/models/gemma-2b"): + """Builds a PublisherModel exposing the given deploy options.""" + return types.PublisherModel( name=name, supported_actions=types.PublisherModelCallToAction( multi_deploy_vertex=types.PublisherModelCallToActionDeployVertex( @@ -464,180 +458,186 @@ def _make_model_with_deploy_options( def test_list_publisher_model_deploy_options_basic(mock_client): - """Tests extraction of a single deploy option into a DeployOption.""" - dummy_model = _make_model_with_deploy_options([_make_deploy_option()]) + """Tests extraction of a single deploy option into a DeployOption.""" + dummy_model = _make_model_with_deploy_options([_make_deploy_option()]) - with mock.patch.object( + with mock.patch.object( mock_client, "_get_publisher_model", return_value=dummy_model ) as mock_get: - # A simplified name is reconciled to the full publisher resource name. - options = mock_client.list_publisher_model_deploy_options( - model="google/gemma3@gemma-3-12b-it" - ) + # A simplified name is reconciled to the full publisher resource name. + options = mock_client.list_publisher_model_deploy_options( + model="google/gemma3@gemma-3-12b-it" + ) - # The GetPublisherModel call must use the reconciled name, flag the model - # as non-Hugging-Face (it has an @version), and request equivalent - # deployment configs. - mock_get.assert_called_once_with( - name="publishers/google/models/gemma3@gemma-3-12b-it", - config=types.GetPublisherModelConfig( - is_hugging_face_model=False, - include_equivalent_model_garden_model_deployment_configs=True, - ), - ) - assert len(options) == 1 - assert isinstance(options[0], types.DeployOption) - assert options[0].option_name == "option-1" - assert options[0].machine_type == "g2-standard-12" - # accelerator_type is returned as the enum's string value (legacy parity). - assert options[0].accelerator_type == "NVIDIA_L4" - assert options[0].accelerator_count == 1 - assert "vllm" in options[0].serving_container_image_uri + # The GetPublisherModel call must use the reconciled name, flag the model + # as non-Hugging-Face (it has an @version), and request equivalent + # deployment configs. + mock_get.assert_called_once_with( + name="publishers/google/models/gemma3@gemma-3-12b-it", + config=types.GetPublisherModelConfig( + is_hugging_face_model=False, + include_equivalent_model_garden_model_deployment_configs=True, + ), + ) + assert len(options) == 1 + assert isinstance(options[0], types.DeployOption) + assert options[0].option_name == "option-1" + assert options[0].machine_type == "g2-standard-12" + # accelerator_type is returned as the enum's string value (legacy parity). + assert options[0].accelerator_type == "NVIDIA_L4" + assert options[0].accelerator_count == 1 + assert "vllm" in options[0].serving_container_image_uri def test_list_publisher_model_deploy_options_multiple(mock_client): - """Tests that all deploy options are returned when no filters are set.""" - dummy_model = _make_model_with_deploy_options( + """Tests that all deploy options are returned when no filters are set.""" + dummy_model = _make_model_with_deploy_options( [ _make_deploy_option(deploy_task_name="g2", machine_type="g2-standard-12"), _make_deploy_option(deploy_task_name="a3", machine_type="a3-highgpu-8g"), ] ) - with mock.patch.object( - mock_client, "_get_publisher_model", return_value=dummy_model - ): - options = mock_client.list_publisher_model_deploy_options( - model="publishers/google/models/gemma-2b@001" - ) + with mock.patch.object( + mock_client, "_get_publisher_model", return_value=dummy_model + ): + options = mock_client.list_publisher_model_deploy_options( + model="publishers/google/models/gemma-2b@001" + ) - assert [o.option_name for o in options] == ["g2", "a3"] + assert [o.option_name for o in options] == ["g2", "a3"] def test_list_publisher_model_deploy_options_machine_type_filter(mock_client): - """Tests machine_type_filter is a case-insensitive substring match.""" - dummy_model = _make_model_with_deploy_options( + """Tests machine_type_filter is a case-insensitive substring match.""" + dummy_model = _make_model_with_deploy_options( [ _make_deploy_option(deploy_task_name="g2", machine_type="g2-standard-12"), _make_deploy_option(deploy_task_name="a3", machine_type="a3-highgpu-8g"), ] ) - with mock.patch.object( - mock_client, "_get_publisher_model", return_value=dummy_model - ): - options = mock_client.list_publisher_model_deploy_options( - model="publishers/google/models/gemma-2b@001", - config=types.ListPublisherModelDeployOptionsConfig( - machine_type_filter="G2" - ), - ) + with mock.patch.object( + mock_client, "_get_publisher_model", return_value=dummy_model + ): + options = mock_client.list_publisher_model_deploy_options( + model="publishers/google/models/gemma-2b@001", + config=types.ListPublisherModelDeployOptionsConfig( + machine_type_filter="G2" + ), + ) - assert len(options) == 1 - assert options[0].option_name == "g2" + assert len(options) == 1 + assert options[0].option_name == "g2" def test_list_publisher_model_deploy_options_accelerator_type_filter( mock_client, ): - """Tests accelerator_type_filter is a case-insensitive substring match.""" - dummy_model = _make_model_with_deploy_options([ - _make_deploy_option(deploy_task_name="l4", accelerator_type="NVIDIA_L4"), - _make_deploy_option( - deploy_task_name="h100", accelerator_type="NVIDIA_H100_80GB" - ), - ]) - - with mock.patch.object( - mock_client, "_get_publisher_model", return_value=dummy_model - ): - options = mock_client.list_publisher_model_deploy_options( - model="publishers/google/models/gemma-2b@001", - config=types.ListPublisherModelDeployOptionsConfig( - accelerator_type_filter="h100" - ), + """Tests accelerator_type_filter is a case-insensitive substring match.""" + dummy_model = _make_model_with_deploy_options( + [ + _make_deploy_option(deploy_task_name="l4", accelerator_type="NVIDIA_L4"), + _make_deploy_option( + deploy_task_name="h100", accelerator_type="NVIDIA_H100_80GB" + ), + ] ) - assert len(options) == 1 - assert options[0].option_name == "h100" + with mock.patch.object( + mock_client, "_get_publisher_model", return_value=dummy_model + ): + options = mock_client.list_publisher_model_deploy_options( + model="publishers/google/models/gemma-2b@001", + config=types.ListPublisherModelDeployOptionsConfig( + accelerator_type_filter="h100" + ), + ) + + assert len(options) == 1 + assert options[0].option_name == "h100" def test_list_publisher_model_deploy_options_image_uri_filter(mock_client): - """Tests serving_container_image_uri_filter is a case-insensitive match.""" - dummy_model = _make_model_with_deploy_options( + """Tests serving_container_image_uri_filter is a case-insensitive match.""" + dummy_model = _make_model_with_deploy_options( [ _make_deploy_option(deploy_task_name="vllm", image_uri="docker/vllm"), _make_deploy_option(deploy_task_name="tgi", image_uri="docker/tgi"), ] ) - with mock.patch.object( - mock_client, "_get_publisher_model", return_value=dummy_model - ): - options = mock_client.list_publisher_model_deploy_options( - model="publishers/google/models/gemma-2b@001", - config=types.ListPublisherModelDeployOptionsConfig( - serving_container_image_uri_filter="VLLM" - ), - ) + with mock.patch.object( + mock_client, "_get_publisher_model", return_value=dummy_model + ): + options = mock_client.list_publisher_model_deploy_options( + model="publishers/google/models/gemma-2b@001", + config=types.ListPublisherModelDeployOptionsConfig( + serving_container_image_uri_filter="VLLM" + ), + ) - assert len(options) == 1 - assert options[0].option_name == "vllm" + assert len(options) == 1 + assert options[0].option_name == "vllm" def test_list_publisher_model_deploy_options_machine_type_filter_list( mock_client, ): - """Tests a list of keywords matches options containing ANY of them (legacy parity).""" - dummy_model = _make_model_with_deploy_options([ - _make_deploy_option(deploy_task_name="g2", machine_type="g2-standard-12"), - _make_deploy_option(deploy_task_name="a3", machine_type="a3-highgpu-8g"), - _make_deploy_option(deploy_task_name="n1", machine_type="n1-standard-8"), - ]) - - with mock.patch.object( - mock_client, "_get_publisher_model", return_value=dummy_model - ): - options = mock_client.list_publisher_model_deploy_options( - model="publishers/google/models/gemma-2b@001", - config=types.ListPublisherModelDeployOptionsConfig( - machine_type_filter=["A3", "n1"] - ), + """Tests a list of keywords matches options containing ANY of them (legacy parity).""" + dummy_model = _make_model_with_deploy_options( + [ + _make_deploy_option(deploy_task_name="g2", machine_type="g2-standard-12"), + _make_deploy_option(deploy_task_name="a3", machine_type="a3-highgpu-8g"), + _make_deploy_option(deploy_task_name="n1", machine_type="n1-standard-8"), + ] ) - assert [o.option_name for o in options] == ["a3", "n1"] + with mock.patch.object( + mock_client, "_get_publisher_model", return_value=dummy_model + ): + options = mock_client.list_publisher_model_deploy_options( + model="publishers/google/models/gemma-2b@001", + config=types.ListPublisherModelDeployOptionsConfig( + machine_type_filter=["A3", "n1"] + ), + ) + + assert [o.option_name for o in options] == ["a3", "n1"] def test_list_publisher_model_deploy_options_accelerator_type_filter_list( mock_client, ): - """Tests a list accelerator filter matches options containing ANY keyword.""" - dummy_model = _make_model_with_deploy_options([ - _make_deploy_option(deploy_task_name="l4", accelerator_type="NVIDIA_L4"), - _make_deploy_option( - deploy_task_name="t4", accelerator_type="NVIDIA_TESLA_T4" - ), - _make_deploy_option( - deploy_task_name="h100", accelerator_type="NVIDIA_H100_80GB" - ), - ]) - - with mock.patch.object( - mock_client, "_get_publisher_model", return_value=dummy_model - ): - options = mock_client.list_publisher_model_deploy_options( - model="publishers/google/models/gemma-2b@001", - config=types.ListPublisherModelDeployOptionsConfig( - accelerator_type_filter=["T4", "L4"] - ), + """Tests a list accelerator filter matches options containing ANY keyword.""" + dummy_model = _make_model_with_deploy_options( + [ + _make_deploy_option(deploy_task_name="l4", accelerator_type="NVIDIA_L4"), + _make_deploy_option( + deploy_task_name="t4", accelerator_type="NVIDIA_TESLA_T4" + ), + _make_deploy_option( + deploy_task_name="h100", accelerator_type="NVIDIA_H100_80GB" + ), + ] ) - assert [o.option_name for o in options] == ["l4", "t4"] + with mock.patch.object( + mock_client, "_get_publisher_model", return_value=dummy_model + ): + options = mock_client.list_publisher_model_deploy_options( + model="publishers/google/models/gemma-2b@001", + config=types.ListPublisherModelDeployOptionsConfig( + accelerator_type_filter=["T4", "L4"] + ), + ) + + assert [o.option_name for o in options] == ["l4", "t4"] def test_list_publisher_model_deploy_options_image_uri_filter_list(mock_client): - """Tests a list image-uri filter matches any keyword.""" - dummy_model = _make_model_with_deploy_options( + """Tests a list image-uri filter matches any keyword.""" + dummy_model = _make_model_with_deploy_options( [ _make_deploy_option(deploy_task_name="vllm", image_uri="docker/vllm"), _make_deploy_option(deploy_task_name="tgi", image_uri="docker/tgi"), @@ -645,101 +645,101 @@ def test_list_publisher_model_deploy_options_image_uri_filter_list(mock_client): ] ) - with mock.patch.object( - mock_client, "_get_publisher_model", return_value=dummy_model - ): - options = mock_client.list_publisher_model_deploy_options( - model="publishers/google/models/gemma-2b@001", - config=types.ListPublisherModelDeployOptionsConfig( - serving_container_image_uri_filter=["vllm", "tgi"] - ), - ) + with mock.patch.object( + mock_client, "_get_publisher_model", return_value=dummy_model + ): + options = mock_client.list_publisher_model_deploy_options( + model="publishers/google/models/gemma-2b@001", + config=types.ListPublisherModelDeployOptionsConfig( + serving_container_image_uri_filter=["vllm", "tgi"] + ), + ) - assert [o.option_name for o in options] == ["vllm", "tgi"] + assert [o.option_name for o in options] == ["vllm", "tgi"] def test_matches_filter(): - """Tests the keyword-filter helper (single keyword, list, None, misses).""" - matches = model_garden.ModelGarden._matches_filter - # No filter -> always matches. - assert matches("g2-standard-12", None) is True - assert matches(None, None) is True - # Single keyword, case-insensitive substring. - assert matches("g2-standard-12", "G2") is True - assert matches("g2-standard-12", "n1") is False - # List of keywords -> match if ANY is contained. - assert matches("a3-highgpu-8g", ["n1", "a3"]) is True - assert matches("a3-highgpu-8g", ["n1", "g2"]) is False - # A missing (None) value never matches a non-empty filter. - assert matches(None, "g2") is False - # An empty list filter behaves like "no filter". - assert matches("anything", []) is True + """Tests the keyword-filter helper (single keyword, list, None, misses).""" + matches = model_garden.ModelGarden._matches_filter + # No filter -> always matches. + assert matches("g2-standard-12", None) is True + assert matches(None, None) is True + # Single keyword, case-insensitive substring. + assert matches("g2-standard-12", "G2") is True + assert matches("g2-standard-12", "n1") is False + # List of keywords -> match if ANY is contained. + assert matches("a3-highgpu-8g", ["n1", "a3"]) is True + assert matches("a3-highgpu-8g", ["n1", "g2"]) is False + # A missing (None) value never matches a non-empty filter. + assert matches(None, "g2") is False + # An empty list filter behaves like "no filter". + assert matches("anything", []) is True def test_list_publisher_model_deploy_options_dict_config(mock_client): - """Tests config passed as a dict is validated and applied.""" - dummy_model = _make_model_with_deploy_options( + """Tests config passed as a dict is validated and applied.""" + dummy_model = _make_model_with_deploy_options( [ _make_deploy_option(deploy_task_name="g2", machine_type="g2-standard-12"), _make_deploy_option(deploy_task_name="a3", machine_type="a3-highgpu-8g"), ] ) - with mock.patch.object( - mock_client, "_get_publisher_model", return_value=dummy_model - ): - options = mock_client.list_publisher_model_deploy_options( - model="publishers/google/models/gemma-2b@001", - config={"machine_type_filter": "a3"}, - ) + with mock.patch.object( + mock_client, "_get_publisher_model", return_value=dummy_model + ): + options = mock_client.list_publisher_model_deploy_options( + model="publishers/google/models/gemma-2b@001", + config={"machine_type_filter": "a3"}, + ) - assert len(options) == 1 - assert options[0].option_name == "a3" + assert len(options) == 1 + assert options[0].option_name == "a3" def test_list_publisher_model_deploy_options_default_config(mock_client): - """Tests config=None returns all options.""" - dummy_model = _make_model_with_deploy_options([_make_deploy_option()]) - - with mock.patch.object( - mock_client, "_get_publisher_model", return_value=dummy_model - ): - options = mock_client.list_publisher_model_deploy_options( - model="publishers/google/models/gemma-2b@001" - ) + """Tests config=None returns all options.""" + dummy_model = _make_model_with_deploy_options([_make_deploy_option()]) - assert len(options) == 1 + with mock.patch.object( + mock_client, "_get_publisher_model", return_value=dummy_model + ): + options = mock_client.list_publisher_model_deploy_options( + model="publishers/google/models/gemma-2b@001" + ) + + assert len(options) == 1 def test_list_publisher_model_deploy_options_no_deploy_support_raises( mock_client, ): - """Tests ValueError when the model does not support deployment (legacy parity).""" - dummy_model = types.PublisherModel(name="publishers/google/models/bert-base") + """Tests ValueError when the model does not support deployment (legacy parity).""" + dummy_model = types.PublisherModel(name="publishers/google/models/bert-base") - with mock.patch.object( - mock_client, "_get_publisher_model", return_value=dummy_model - ): - with pytest.raises(ValueError, match="does not support deployment"): - mock_client.list_publisher_model_deploy_options( - model="publishers/google/models/bert-base@001" - ) + with mock.patch.object( + mock_client, "_get_publisher_model", return_value=dummy_model + ): + with pytest.raises(ValueError, match="does not support deployment"): + mock_client.list_publisher_model_deploy_options( + model="publishers/google/models/bert-base@001" + ) def test_list_publisher_model_deploy_options_no_match_raises(mock_client): - """Tests ValueError when filters exclude every option (legacy parity).""" - dummy_model = _make_model_with_deploy_options([_make_deploy_option()]) + """Tests ValueError when filters exclude every option (legacy parity).""" + dummy_model = _make_model_with_deploy_options([_make_deploy_option()]) - with mock.patch.object( - mock_client, "_get_publisher_model", return_value=dummy_model - ): - with pytest.raises(ValueError, match="No deploy options found."): - mock_client.list_publisher_model_deploy_options( - model="publishers/google/models/gemma-2b@001", - config=types.ListPublisherModelDeployOptionsConfig( - machine_type_filter="does-not-exist" - ), - ) + with mock.patch.object( + mock_client, "_get_publisher_model", return_value=dummy_model + ): + with pytest.raises(ValueError, match="No deploy options found."): + mock_client.list_publisher_model_deploy_options( + model="publishers/google/models/gemma-2b@001", + config=types.ListPublisherModelDeployOptionsConfig( + machine_type_filter="does-not-exist" + ), + ) def test_reconcile_model_name_simplified_with_version(): @@ -776,102 +776,102 @@ def test_reconcile_model_name_lowercases(): def test_reconcile_model_name_invalid_raises(): - """Tests an invalid name raises ValueError.""" - reconcile = model_garden.ModelGarden._reconcile_model_name - with pytest.raises(ValueError, match="not a valid publisher model name"): - reconcile("invalid-name-without-slash") + """Tests an invalid name raises ValueError.""" + reconcile = model_garden.ModelGarden._reconcile_model_name + with pytest.raises(ValueError, match="not a valid publisher model name"): + reconcile("invalid-name-without-slash") def test_reconcile_model_name_model_registry_raises(): - """Tests a Model Registry resource name raises ValueError. - - Without an explicit guard, ``projects/.../locations/.../models/...`` would - match the simplified ``{publisher}/{model}`` regex and be silently mangled - into ``publishers/projects/models/.../locations/.../models/...``. The guard - rejects it loudly with the same ``not a valid publisher model name`` - message used for any other unsupported input. - """ - reconcile = model_garden.ModelGarden._reconcile_model_name - for name in ( - "projects/123/locations/us-central1/models/456", - "projects/my-project/locations/europe-west1/models/9876543210@1", - "projects/p/locations/l/models/m", - ): - with pytest.raises(ValueError, match="not a valid publisher model name"): - reconcile(name) + """Tests a Model Registry resource name raises ValueError. + + Without an explicit guard, ``projects/.../locations/.../models/...`` would + match the simplified ``{publisher}/{model}`` regex and be silently mangled + into ``publishers/projects/models/.../locations/.../models/...``. The guard + rejects it loudly with the same ``not a valid publisher model name`` + message used for any other unsupported input. + """ + reconcile = model_garden.ModelGarden._reconcile_model_name + for name in ( + "projects/123/locations/us-central1/models/456", + "projects/my-project/locations/europe-west1/models/9876543210@1", + "projects/p/locations/l/models/m", + ): + with pytest.raises(ValueError, match="not a valid publisher model name"): + reconcile(name) def test_is_hugging_face_model(): - """Tests the Hugging Face model heuristic.""" - is_hf = model_garden.ModelGarden._is_hugging_face_model - # Bare org/model (single slash, no @version) -> Hugging Face. - assert is_hf("meta-llama/Llama-3.3-70B-Instruct") is True - # Simplified native names without @version also match (handled - # correctly by _reconcile_model_name). - assert is_hf("google/gemma3") is True - # Names with @version or a publishers/ prefix are not Hugging Face. - assert is_hf("google/gemma3@gemma-3-12b-it") is False - assert is_hf("publishers/google/models/gemma3@gemma-3-12b-it") is False - assert is_hf("publishers/hf-meta-llama/models/llama-3.3") is False + """Tests the Hugging Face model heuristic.""" + is_hf = model_garden.ModelGarden._is_hugging_face_model + # Bare org/model (single slash, no @version) -> Hugging Face. + assert is_hf("meta-llama/Llama-3.3-70B-Instruct") is True + # Simplified native names without @version also match (handled + # correctly by _reconcile_model_name). + assert is_hf("google/gemma3") is True + # Names with @version or a publishers/ prefix are not Hugging Face. + assert is_hf("google/gemma3@gemma-3-12b-it") is False + assert is_hf("publishers/google/models/gemma3@gemma-3-12b-it") is False + assert is_hf("publishers/hf-meta-llama/models/llama-3.3") is False def test_list_publisher_model_deploy_options_hugging_face_model(mock_client): - """Tests an HF model name sends is_hugging_face_model=True (legacy parity).""" - dummy_model = _make_model_with_deploy_options([_make_deploy_option()]) + """Tests an HF model name sends is_hugging_face_model=True (legacy parity).""" + dummy_model = _make_model_with_deploy_options([_make_deploy_option()]) - with mock.patch.object( + with mock.patch.object( mock_client, "_get_publisher_model", return_value=dummy_model ) as mock_get: - mock_client.list_publisher_model_deploy_options( - model="meta-llama/Llama-3.3-70B-Instruct" - ) + mock_client.list_publisher_model_deploy_options( + model="meta-llama/Llama-3.3-70B-Instruct" + ) - mock_get.assert_called_once_with( - name="publishers/meta-llama/models/llama-3.3-70b-instruct", - config=types.GetPublisherModelConfig( - is_hugging_face_model=True, - include_equivalent_model_garden_model_deployment_configs=True, - ), - ) + mock_get.assert_called_once_with( + name="publishers/meta-llama/models/llama-3.3-70b-instruct", + config=types.GetPublisherModelConfig( + is_hugging_face_model=True, + include_equivalent_model_garden_model_deployment_configs=True, + ), + ) def test_list_publisher_model_deploy_options_async(mock_async_client): - """Tests the async client returns deploy options.""" - dummy_model = _make_model_with_deploy_options([_make_deploy_option()]) + """Tests the async client returns deploy options.""" + dummy_model = _make_model_with_deploy_options([_make_deploy_option()]) - with mock.patch.object( + with mock.patch.object( mock_async_client, "_get_publisher_model", new=mock.AsyncMock(return_value=dummy_model), ): - options = asyncio.run( - mock_async_client.list_publisher_model_deploy_options( - model="publishers/google/models/gemma-2b@001" + options = asyncio.run( + mock_async_client.list_publisher_model_deploy_options( + model="publishers/google/models/gemma-2b@001" + ) ) - ) - assert len(options) == 1 - assert options[0].option_name == "option-1" - assert options[0].machine_type == "g2-standard-12" + assert len(options) == 1 + assert options[0].option_name == "option-1" + assert options[0].machine_type == "g2-standard-12" def test_list_publisher_model_deploy_options_async_no_deploy_support_raises( mock_async_client, ): - """Tests the async client raises when deployment is unsupported.""" - dummy_model = types.PublisherModel(name="publishers/google/models/bert-base") + """Tests the async client raises when deployment is unsupported.""" + dummy_model = types.PublisherModel(name="publishers/google/models/bert-base") - with mock.patch.object( + with mock.patch.object( mock_async_client, "_get_publisher_model", new=mock.AsyncMock(return_value=dummy_model), ): - with pytest.raises(ValueError, match="does not support deployment"): - asyncio.run( - mock_async_client.list_publisher_model_deploy_options( - model="publishers/google/models/bert-base@001" - ) - ) + with pytest.raises(ValueError, match="does not support deployment"): + asyncio.run( + mock_async_client.list_publisher_model_deploy_options( + model="publishers/google/models/bert-base@001" + ) + ) def _make_deploy_option_no_accelerator( @@ -879,8 +879,8 @@ def _make_deploy_option_no_accelerator( machine_type="ct5lp-hightpu-1t", image_uri="docker/hexllm", ): - """Builds a deploy option whose machine has no GPU accelerator (e.g. TPU).""" - return types.PublisherModelCallToActionDeploy( + """Builds a deploy option whose machine has no GPU accelerator (e.g. TPU).""" + return types.PublisherModelCallToActionDeploy( deploy_task_name=deploy_task_name, dedicated_resources=types.DedicatedResources( machine_spec=types.MachineSpec(machine_type=machine_type) @@ -892,72 +892,72 @@ def _make_deploy_option_no_accelerator( def test_list_publisher_model_deploy_options_no_accelerator_defaults( mock_client, ): - """Tests no-accelerator machines report UNSPECIFIED/0 (legacy proto parity).""" - dummy_model = _make_model_with_deploy_options( - [_make_deploy_option_no_accelerator()] - ) - - with mock.patch.object( - mock_client, "_get_publisher_model", return_value=dummy_model - ): - options = mock_client.list_publisher_model_deploy_options( - model="publishers/google/models/gemma-2b@001" + """Tests no-accelerator machines report UNSPECIFIED/0 (legacy proto parity).""" + dummy_model = _make_model_with_deploy_options( + [_make_deploy_option_no_accelerator()] ) - assert options[0].machine_type == "ct5lp-hightpu-1t" - # Over gRPC the legacy SDK surfaces these proto3 defaults; we match them. - assert options[0].accelerator_type == "ACCELERATOR_TYPE_UNSPECIFIED" - assert options[0].accelerator_count == 0 + with mock.patch.object( + mock_client, "_get_publisher_model", return_value=dummy_model + ): + options = mock_client.list_publisher_model_deploy_options( + model="publishers/google/models/gemma-2b@001" + ) + + assert options[0].machine_type == "ct5lp-hightpu-1t" + # Over gRPC the legacy SDK surfaces these proto3 defaults; we match them. + assert options[0].accelerator_type == "ACCELERATOR_TYPE_UNSPECIFIED" + assert options[0].accelerator_count == 0 def test_list_publisher_model_deploy_options_no_accelerator_concise( mock_client, ): - """Tests concise output for a no-accelerator machine matches legacy.""" - dummy_model = _make_model_with_deploy_options( - [_make_deploy_option_no_accelerator()] - ) - - with mock.patch.object( - mock_client, "_get_publisher_model", return_value=dummy_model - ): - result = mock_client.list_publisher_model_deploy_options( - model="publishers/google/models/gemma-2b@001", - config=types.ListPublisherModelDeployOptionsConfig(concise=True), + """Tests concise output for a no-accelerator machine matches legacy.""" + dummy_model = _make_model_with_deploy_options( + [_make_deploy_option_no_accelerator()] ) - expected = ( - "[Option 1: tpu]\n" - ' serving_container_image_uri="docker/hexllm",\n' - ' machine_type="ct5lp-hightpu-1t",\n' - ' accelerator_type="ACCELERATOR_TYPE_UNSPECIFIED",\n' - " accelerator_count=0," - ) - assert result == expected + with mock.patch.object( + mock_client, "_get_publisher_model", return_value=dummy_model + ): + result = mock_client.list_publisher_model_deploy_options( + model="publishers/google/models/gemma-2b@001", + config=types.ListPublisherModelDeployOptionsConfig(concise=True), + ) + + expected = ( + "[Option 1: tpu]\n" + ' serving_container_image_uri="docker/hexllm",\n' + ' machine_type="ct5lp-hightpu-1t",\n' + ' accelerator_type="ACCELERATOR_TYPE_UNSPECIFIED",\n' + " accelerator_count=0," + ) + assert result == expected def test_list_publisher_model_deploy_options_accelerator_filter_excludes_unspecified( mock_client, ): - """Tests accelerator_type_filter excludes no-accelerator options (legacy parity).""" - dummy_model = _make_model_with_deploy_options( + """Tests accelerator_type_filter excludes no-accelerator options (legacy parity).""" + dummy_model = _make_model_with_deploy_options( [ _make_deploy_option_no_accelerator(deploy_task_name="tpu"), _make_deploy_option(deploy_task_name="gpu", accelerator_type="NVIDIA_L4"), ] ) - with mock.patch.object( - mock_client, "_get_publisher_model", return_value=dummy_model - ): - options = mock_client.list_publisher_model_deploy_options( - model="publishers/google/models/gemma-2b@001", - config=types.ListPublisherModelDeployOptionsConfig( - accelerator_type_filter="NVIDIA" - ), - ) + with mock.patch.object( + mock_client, "_get_publisher_model", return_value=dummy_model + ): + options = mock_client.list_publisher_model_deploy_options( + model="publishers/google/models/gemma-2b@001", + config=types.ListPublisherModelDeployOptionsConfig( + accelerator_type_filter="NVIDIA" + ), + ) - assert [o.option_name for o in options] == ["gpu"] + assert [o.option_name for o in options] == ["gpu"] # ---- concise option tests ---- @@ -966,58 +966,60 @@ def test_list_publisher_model_deploy_options_accelerator_filter_excludes_unspeci def test_list_publisher_model_deploy_options_not_concise_returns_list( mock_client, ): - """Tests that without concise the method returns a list of DeployOption.""" - dummy_model = _make_model_with_deploy_options([_make_deploy_option()]) - - with mock.patch.object( - mock_client, "_get_publisher_model", return_value=dummy_model - ): - result = mock_client.list_publisher_model_deploy_options( - model="publishers/google/models/gemma-2b@001", - config=types.ListPublisherModelDeployOptionsConfig(concise=False), - ) + """Tests that without concise the method returns a list of DeployOption.""" + dummy_model = _make_model_with_deploy_options([_make_deploy_option()]) - assert isinstance(result, list) - assert isinstance(result[0], types.DeployOption) + with mock.patch.object( + mock_client, "_get_publisher_model", return_value=dummy_model + ): + result = mock_client.list_publisher_model_deploy_options( + model="publishers/google/models/gemma-2b@001", + config=types.ListPublisherModelDeployOptionsConfig(concise=False), + ) + + assert isinstance(result, list) + assert isinstance(result[0], types.DeployOption) def test_list_publisher_model_deploy_options_concise_returns_string( mock_client, ): - """Tests concise=True returns the legacy-format human-readable string.""" - dummy_model = _make_model_with_deploy_options([ - _make_deploy_option( - deploy_task_name="option-1", - machine_type="g2-standard-12", - accelerator_type="NVIDIA_L4", - accelerator_count=1, - image_uri="docker/vllm", - ) - ]) - - with mock.patch.object( - mock_client, "_get_publisher_model", return_value=dummy_model - ): - result = mock_client.list_publisher_model_deploy_options( - model="publishers/google/models/gemma-2b@001", - config=types.ListPublisherModelDeployOptionsConfig(concise=True), + """Tests concise=True returns the legacy-format human-readable string.""" + dummy_model = _make_model_with_deploy_options( + [ + _make_deploy_option( + deploy_task_name="option-1", + machine_type="g2-standard-12", + accelerator_type="NVIDIA_L4", + accelerator_count=1, + image_uri="docker/vllm", + ) + ] ) - # Matches the legacy SDK's concise formatting exactly. - expected = ( - "[Option 1: option-1]\n" - ' serving_container_image_uri="docker/vllm",\n' - ' machine_type="g2-standard-12",\n' - ' accelerator_type="NVIDIA_L4",\n' - " accelerator_count=1," - ) - assert isinstance(result, str) - assert result == expected + with mock.patch.object( + mock_client, "_get_publisher_model", return_value=dummy_model + ): + result = mock_client.list_publisher_model_deploy_options( + model="publishers/google/models/gemma-2b@001", + config=types.ListPublisherModelDeployOptionsConfig(concise=True), + ) + + # Matches the legacy SDK's concise formatting exactly. + expected = ( + "[Option 1: option-1]\n" + ' serving_container_image_uri="docker/vllm",\n' + ' machine_type="g2-standard-12",\n' + ' accelerator_type="NVIDIA_L4",\n' + " accelerator_count=1," + ) + assert isinstance(result, str) + assert result == expected def test_list_publisher_model_deploy_options_concise_multiple(mock_client): - """Tests concise formatting of multiple options separated by a blank line.""" - dummy_model = _make_model_with_deploy_options( + """Tests concise formatting of multiple options separated by a blank line.""" + dummy_model = _make_model_with_deploy_options( [ _make_deploy_option( deploy_task_name="g2", @@ -1036,74 +1038,72 @@ def test_list_publisher_model_deploy_options_concise_multiple(mock_client): ] ) - with mock.patch.object( - mock_client, "_get_publisher_model", return_value=dummy_model - ): - result = mock_client.list_publisher_model_deploy_options( - model="publishers/google/models/gemma-2b@001", - config=types.ListPublisherModelDeployOptionsConfig(concise=True), - ) + with mock.patch.object( + mock_client, "_get_publisher_model", return_value=dummy_model + ): + result = mock_client.list_publisher_model_deploy_options( + model="publishers/google/models/gemma-2b@001", + config=types.ListPublisherModelDeployOptionsConfig(concise=True), + ) - expected = ( - "[Option 1: g2]\n" - ' serving_container_image_uri="docker/vllm",\n' - ' machine_type="g2-standard-12",\n' - ' accelerator_type="NVIDIA_L4",\n' - " accelerator_count=1," - "\n\n" - "[Option 2: a3]\n" - ' serving_container_image_uri="docker/hexllm",\n' - ' machine_type="a3-highgpu-8g",\n' - ' accelerator_type="NVIDIA_H100_80GB",\n' - " accelerator_count=8," - ) - assert result == expected + expected = ( + "[Option 1: g2]\n" + ' serving_container_image_uri="docker/vllm",\n' + ' machine_type="g2-standard-12",\n' + ' accelerator_type="NVIDIA_L4",\n' + " accelerator_count=1," + "\n\n" + "[Option 2: a3]\n" + ' serving_container_image_uri="docker/hexllm",\n' + ' machine_type="a3-highgpu-8g",\n' + ' accelerator_type="NVIDIA_H100_80GB",\n' + " accelerator_count=8," + ) + assert result == expected def test_list_publisher_model_deploy_options_concise_with_filter(mock_client): - """Tests concise output honors filters before formatting.""" - dummy_model = _make_model_with_deploy_options( + """Tests concise output honors filters before formatting.""" + dummy_model = _make_model_with_deploy_options( [ _make_deploy_option(deploy_task_name="g2", machine_type="g2-standard-12"), _make_deploy_option(deploy_task_name="a3", machine_type="a3-highgpu-8g"), ] ) - with mock.patch.object( - mock_client, "_get_publisher_model", return_value=dummy_model - ): - result = mock_client.list_publisher_model_deploy_options( - model="publishers/google/models/gemma-2b@001", - config=types.ListPublisherModelDeployOptionsConfig( - machine_type_filter="a3", concise=True - ), - ) + with mock.patch.object( + mock_client, "_get_publisher_model", return_value=dummy_model + ): + result = mock_client.list_publisher_model_deploy_options( + model="publishers/google/models/gemma-2b@001", + config=types.ListPublisherModelDeployOptionsConfig( + machine_type_filter="a3", concise=True + ), + ) - assert result.startswith("[Option 1: a3]") - assert "a3-highgpu-8g" in result - assert "g2-standard-12" not in result + assert result.startswith("[Option 1: a3]") + assert "a3-highgpu-8g" in result + assert "g2-standard-12" not in result def test_format_concise_deploy_options_omits_none_fields(): - """Tests that None fields are omitted and the header has no name if unset.""" - options = [ + """Tests that None fields are omitted and the header has no name if unset.""" + options = [ types.DeployOption( machine_type="g2-standard-12", accelerator_count=1, ) ] - result = model_garden.ModelGarden._format_concise_deploy_options(options) - expected = ( - "[Option 1]\n" - ' machine_type="g2-standard-12",\n' - " accelerator_count=1," + result = model_garden.ModelGarden._format_concise_deploy_options(options) + expected = ( + "[Option 1]\n" ' machine_type="g2-standard-12",\n' " accelerator_count=1," ) - assert result == expected + assert result == expected def test_list_publisher_model_deploy_options_concise_async(mock_async_client): - """Tests the async client returns a concise string when requested.""" - dummy_model = _make_model_with_deploy_options( + """Tests the async client returns a concise string when requested.""" + dummy_model = _make_model_with_deploy_options( [ _make_deploy_option( deploy_task_name="option-1", @@ -1115,287 +1115,625 @@ def test_list_publisher_model_deploy_options_concise_async(mock_async_client): ] ) - with mock.patch.object( + with mock.patch.object( mock_async_client, "_get_publisher_model", new=mock.AsyncMock(return_value=dummy_model), ): - result = asyncio.run( - mock_async_client.list_publisher_model_deploy_options( - model="publishers/google/models/gemma-2b@001", - config=types.ListPublisherModelDeployOptionsConfig(concise=True), + result = asyncio.run( + mock_async_client.list_publisher_model_deploy_options( + model="publishers/google/models/gemma-2b@001", + config=types.ListPublisherModelDeployOptionsConfig(concise=True), + ) ) - ) - assert isinstance(result, str) - assert result.startswith("[Option 1: option-1]") + assert isinstance(result, str) + assert result.startswith("[Option 1: option-1]") def test_list_custom_model_deploy_options_specs_path(mock_client): - """Tests the specs branch is extracted and formatted like the legacy SDK.""" - dummy_response = types.RecommendSpecResponse( - specs=[ - types.RecommendSpecResponseMachineAndModelContainerSpec( - machine_spec=types.MachineSpec( - machine_type="g2-standard-12", - accelerator_type="NVIDIA_L4", - accelerator_count=1, - ) - ) - ] - ) - - with mock.patch.object( - mock_client, "_recommend_spec", return_value=dummy_response - ): - result = mock_client.list_custom_model_deploy_options(src="gs://weights") - - expected = ( - "[Option 1]\n" - ' machine_type="g2-standard-12",\n' - ' accelerator_type="NVIDIA_L4",\n' - " accelerator_count=1" + """Tests the specs branch is extracted and formatted like the legacy SDK.""" + dummy_response = types.RecommendSpecResponse( + specs=[ + types.RecommendSpecResponseMachineAndModelContainerSpec( + machine_spec=types.MachineSpec( + machine_type="g2-standard-12", + accelerator_type="NVIDIA_L4", + accelerator_count=1, + ) + ) + ] ) - assert result == expected + + with mock.patch.object(mock_client, "_recommend_spec", return_value=dummy_response): + result = mock_client.list_custom_model_deploy_options(src="gs://weights") + + expected = ( + "[Option 1]\n" + ' machine_type="g2-standard-12",\n' + ' accelerator_type="NVIDIA_L4",\n' + " accelerator_count=1" + ) + assert result == expected def test_list_custom_model_deploy_options_request_config(mock_client): - """Tests the parent, gcs_uri and RecommendSpecConfig are passed through.""" - dummy_response = types.RecommendSpecResponse( - specs=[ - types.RecommendSpecResponseMachineAndModelContainerSpec( - machine_spec=types.MachineSpec(machine_type="g2-standard-12") - ) - ] - ) - - with mock.patch.object( - mock_client, "_recommend_spec", return_value=dummy_response - ) as mock_recommend: - mock_client.list_custom_model_deploy_options(src="gs://weights") - - mock_recommend.assert_called_once_with( - parent=f"projects/{_TEST_PROJECT}/locations/{_TEST_LOCATION}", - gcs_uri="gs://weights", - config=types.RecommendSpecConfig( - check_machine_availability=True, - check_user_quota=True, - ), + """Tests the parent, gcs_uri and RecommendSpecConfig are passed through.""" + dummy_response = types.RecommendSpecResponse( + specs=[ + types.RecommendSpecResponseMachineAndModelContainerSpec( + machine_spec=types.MachineSpec(machine_type="g2-standard-12") + ) + ] ) + with mock.patch.object( + mock_client, "_recommend_spec", return_value=dummy_response + ) as mock_recommend: + mock_client.list_custom_model_deploy_options(src="gs://weights") + + mock_recommend.assert_called_once_with( + parent=f"projects/{_TEST_PROJECT}/locations/{_TEST_LOCATION}", + gcs_uri="gs://weights", + config=types.RecommendSpecConfig( + check_machine_availability=True, + check_user_quota=True, + ), + ) + def test_list_custom_model_deploy_options_config_flags_forwarded(mock_client): - """Tests check_machine_availability/filter_by_user_quota map to the API config.""" - dummy_response = types.RecommendSpecResponse( - specs=[ - types.RecommendSpecResponseMachineAndModelContainerSpec( - machine_spec=types.MachineSpec(machine_type="g2-standard-12") - ) - ] - ) - - with mock.patch.object( - mock_client, "_recommend_spec", return_value=dummy_response - ) as mock_recommend: - mock_client.list_custom_model_deploy_options( - src="gs://weights", - config=types.ListCustomModelDeployOptionsConfig( - check_machine_availability=False, - filter_by_user_quota=False, - ), + """Tests check_machine_availability/filter_by_user_quota map to the API config.""" + dummy_response = types.RecommendSpecResponse( + specs=[ + types.RecommendSpecResponseMachineAndModelContainerSpec( + machine_spec=types.MachineSpec(machine_type="g2-standard-12") + ) + ] ) - _, kwargs = mock_recommend.call_args - assert kwargs["config"] == types.RecommendSpecConfig( - check_machine_availability=False, - check_user_quota=False, - ) + with mock.patch.object( + mock_client, "_recommend_spec", return_value=dummy_response + ) as mock_recommend: + mock_client.list_custom_model_deploy_options( + src="gs://weights", + config=types.ListCustomModelDeployOptionsConfig( + check_machine_availability=False, + filter_by_user_quota=False, + ), + ) + + _, kwargs = mock_recommend.call_args + assert kwargs["config"] == types.RecommendSpecConfig( + check_machine_availability=False, + check_user_quota=False, + ) def test_list_custom_model_deploy_options_recommendations_quota_filter( mock_client, ): - """Tests recommendations are filtered to QUOTA_STATE_USER_HAS_QUOTA by default.""" - dummy_response = types.RecommendSpecResponse( - recommendations=[ - types.RecommendSpecResponseRecommendation( - region="us-central1", - spec=types.RecommendSpecResponseMachineAndModelContainerSpec( - machine_spec=types.MachineSpec( - machine_type="g2-standard-12", - accelerator_type="NVIDIA_L4", - accelerator_count=1, - ) - ), - user_quota_state="QUOTA_STATE_USER_HAS_QUOTA", - ), - types.RecommendSpecResponseRecommendation( - region="us-west1", - spec=types.RecommendSpecResponseMachineAndModelContainerSpec( - machine_spec=types.MachineSpec( - machine_type="a3-highgpu-8g", - accelerator_type="NVIDIA_H100_80GB", - accelerator_count=8, - ) - ), - user_quota_state="QUOTA_STATE_NO_USER_QUOTA", - ), - ] - ) - - with mock.patch.object( - mock_client, "_recommend_spec", return_value=dummy_response - ): - result = mock_client.list_custom_model_deploy_options(src="gs://weights") - - expected = ( - "[Option 1]\n" - ' machine_type="g2-standard-12",\n' - ' accelerator_type="NVIDIA_L4",\n' - " accelerator_count=1,\n" - ' region="us-central1",\n' - ' user_quota_state="QUOTA_STATE_USER_HAS_QUOTA"' + """Tests recommendations are filtered to QUOTA_STATE_USER_HAS_QUOTA by default.""" + dummy_response = types.RecommendSpecResponse( + recommendations=[ + types.RecommendSpecResponseRecommendation( + region="us-central1", + spec=types.RecommendSpecResponseMachineAndModelContainerSpec( + machine_spec=types.MachineSpec( + machine_type="g2-standard-12", + accelerator_type="NVIDIA_L4", + accelerator_count=1, + ) + ), + user_quota_state="QUOTA_STATE_USER_HAS_QUOTA", + ), + types.RecommendSpecResponseRecommendation( + region="us-west1", + spec=types.RecommendSpecResponseMachineAndModelContainerSpec( + machine_spec=types.MachineSpec( + machine_type="a3-highgpu-8g", + accelerator_type="NVIDIA_H100_80GB", + accelerator_count=8, + ) + ), + user_quota_state="QUOTA_STATE_NO_USER_QUOTA", + ), + ] ) - assert result == expected + + with mock.patch.object(mock_client, "_recommend_spec", return_value=dummy_response): + result = mock_client.list_custom_model_deploy_options(src="gs://weights") + + expected = ( + "[Option 1]\n" + ' machine_type="g2-standard-12",\n' + ' accelerator_type="NVIDIA_L4",\n' + " accelerator_count=1,\n" + ' region="us-central1",\n' + ' user_quota_state="QUOTA_STATE_USER_HAS_QUOTA"' + ) + assert result == expected def test_list_custom_model_deploy_options_recommendations_no_quota_filter( mock_client, ): - """Tests filter_by_user_quota=False keeps every recommendation.""" - dummy_response = types.RecommendSpecResponse( - recommendations=[ - types.RecommendSpecResponseRecommendation( - region="us-central1", - spec=types.RecommendSpecResponseMachineAndModelContainerSpec( - machine_spec=types.MachineSpec(machine_type="g2-standard-12") - ), - user_quota_state="QUOTA_STATE_USER_HAS_QUOTA", - ), - types.RecommendSpecResponseRecommendation( - region="us-west1", - spec=types.RecommendSpecResponseMachineAndModelContainerSpec( - machine_spec=types.MachineSpec(machine_type="a3-highgpu-8g") - ), - user_quota_state="QUOTA_STATE_NO_USER_QUOTA", - ), - ] - ) - - with mock.patch.object( - mock_client, "_recommend_spec", return_value=dummy_response - ): - result = mock_client.list_custom_model_deploy_options( - src="gs://weights", - config=types.ListCustomModelDeployOptionsConfig( - filter_by_user_quota=False - ), + """Tests filter_by_user_quota=False keeps every recommendation.""" + dummy_response = types.RecommendSpecResponse( + recommendations=[ + types.RecommendSpecResponseRecommendation( + region="us-central1", + spec=types.RecommendSpecResponseMachineAndModelContainerSpec( + machine_spec=types.MachineSpec(machine_type="g2-standard-12") + ), + user_quota_state="QUOTA_STATE_USER_HAS_QUOTA", + ), + types.RecommendSpecResponseRecommendation( + region="us-west1", + spec=types.RecommendSpecResponseMachineAndModelContainerSpec( + machine_spec=types.MachineSpec(machine_type="a3-highgpu-8g") + ), + user_quota_state="QUOTA_STATE_NO_USER_QUOTA", + ), + ] ) - assert "[Option 1]" in result - assert "[Option 2]" in result - assert "us-west1" in result + with mock.patch.object(mock_client, "_recommend_spec", return_value=dummy_response): + result = mock_client.list_custom_model_deploy_options( + src="gs://weights", + config=types.ListCustomModelDeployOptionsConfig(filter_by_user_quota=False), + ) + + assert "[Option 1]" in result + assert "[Option 2]" in result + assert "us-west1" in result def test_list_custom_model_deploy_options_src_required_raises(mock_client): - """Tests an empty src raises ValueError, matching the legacy SDK guard.""" - with pytest.raises(ValueError, match="src must be specified"): - mock_client.list_custom_model_deploy_options(src="") + """Tests an empty src raises ValueError, matching the legacy SDK guard.""" + with pytest.raises(ValueError, match="src must be specified"): + mock_client.list_custom_model_deploy_options(src="") def test_list_custom_model_deploy_options_empty_options_raises(mock_client): - """Tests an empty API response raises, matching the publisher-model sibling.""" - dummy_response = types.RecommendSpecResponse(specs=[]) + """Tests an empty API response raises, matching the publisher-model sibling.""" + dummy_response = types.RecommendSpecResponse(specs=[]) - with mock.patch.object( - mock_client, "_recommend_spec", return_value=dummy_response - ): - with pytest.raises(ValueError, match="No deploy options found"): - mock_client.list_custom_model_deploy_options(src="gs://weights") + with mock.patch.object(mock_client, "_recommend_spec", return_value=dummy_response): + with pytest.raises(ValueError, match="No deploy options found"): + mock_client.list_custom_model_deploy_options(src="gs://weights") def test_list_custom_model_deploy_options_quota_filter_empty_raises( mock_client, ): - """Tests filter_by_user_quota dropping every recommendation raises.""" - dummy_response = types.RecommendSpecResponse( - recommendations=[ - types.RecommendSpecResponseRecommendation( - region="us-central1", - spec=types.RecommendSpecResponseMachineAndModelContainerSpec( - machine_spec=types.MachineSpec(machine_type="g2-standard-12") - ), - user_quota_state="QUOTA_STATE_NO_USER_QUOTA", - ), - ] - ) - - with mock.patch.object( - mock_client, "_recommend_spec", return_value=dummy_response - ): - with pytest.raises(ValueError, match="No deploy options found"): - mock_client.list_custom_model_deploy_options(src="gs://weights") + """Tests filter_by_user_quota dropping every recommendation raises.""" + dummy_response = types.RecommendSpecResponse( + recommendations=[ + types.RecommendSpecResponseRecommendation( + region="us-central1", + spec=types.RecommendSpecResponseMachineAndModelContainerSpec( + machine_spec=types.MachineSpec(machine_type="g2-standard-12") + ), + user_quota_state="QUOTA_STATE_NO_USER_QUOTA", + ), + ] + ) + + with mock.patch.object(mock_client, "_recommend_spec", return_value=dummy_response): + with pytest.raises(ValueError, match="No deploy options found"): + mock_client.list_custom_model_deploy_options(src="gs://weights") def test_list_custom_model_deploy_options_dict_config(mock_client): - """Tests a dict config is validated into ListCustomModelDeployOptionsConfig.""" - dummy_response = types.RecommendSpecResponse( - specs=[ - types.RecommendSpecResponseMachineAndModelContainerSpec( - machine_spec=types.MachineSpec(machine_type="g2-standard-12") - ) - ] - ) - - with mock.patch.object( - mock_client, "_recommend_spec", return_value=dummy_response - ) as mock_recommend: - mock_client.list_custom_model_deploy_options( - src="gs://weights", - config={ - "check_machine_availability": False, - "filter_by_user_quota": False, - }, + """Tests a dict config is validated into ListCustomModelDeployOptionsConfig.""" + dummy_response = types.RecommendSpecResponse( + specs=[ + types.RecommendSpecResponseMachineAndModelContainerSpec( + machine_spec=types.MachineSpec(machine_type="g2-standard-12") + ) + ] ) - _, kwargs = mock_recommend.call_args - assert kwargs["config"] == types.RecommendSpecConfig( - check_machine_availability=False, - check_user_quota=False, - ) + with mock.patch.object( + mock_client, "_recommend_spec", return_value=dummy_response + ) as mock_recommend: + mock_client.list_custom_model_deploy_options( + src="gs://weights", + config={ + "check_machine_availability": False, + "filter_by_user_quota": False, + }, + ) + + _, kwargs = mock_recommend.call_args + assert kwargs["config"] == types.RecommendSpecConfig( + check_machine_availability=False, + check_user_quota=False, + ) def test_list_custom_model_deploy_options_async(mock_async_client): - """Tests the async client returns the formatted deploy options string.""" - dummy_response = types.RecommendSpecResponse( - specs=[ - types.RecommendSpecResponseMachineAndModelContainerSpec( - machine_spec=types.MachineSpec( - machine_type="g2-standard-12", - accelerator_type="NVIDIA_L4", - accelerator_count=1, - ) - ) - ] - ) - - with mock.patch.object( - mock_async_client, - "_recommend_spec", - new=mock.AsyncMock(return_value=dummy_response), - ): - result = asyncio.run( - mock_async_client.list_custom_model_deploy_options(src="gs://weights") + """Tests the async client returns the formatted deploy options string.""" + dummy_response = types.RecommendSpecResponse( + specs=[ + types.RecommendSpecResponseMachineAndModelContainerSpec( + machine_spec=types.MachineSpec( + machine_type="g2-standard-12", + accelerator_type="NVIDIA_L4", + accelerator_count=1, + ) + ) + ] ) - assert isinstance(result, str) - assert result.startswith("[Option 1]") - assert 'machine_type="g2-standard-12"' in result + with mock.patch.object( + mock_async_client, + "_recommend_spec", + new=mock.AsyncMock(return_value=dummy_response), + ): + result = asyncio.run( + mock_async_client.list_custom_model_deploy_options(src="gs://weights") + ) + + assert isinstance(result, str) + assert result.startswith("[Option 1]") + assert 'machine_type="g2-standard-12"' in result def test_list_custom_model_deploy_options_async_src_required_raises( mock_async_client, ): - """Tests the async client also validates src.""" - with pytest.raises(ValueError, match="src must be specified"): - asyncio.run(mock_async_client.list_custom_model_deploy_options(src="")) + """Tests the async client also validates src.""" + with pytest.raises(ValueError, match="src must be specified"): + asyncio.run(mock_async_client.list_custom_model_deploy_options(src="")) + + +# ---- deploy_publisher_model tests ------------------------------------- + + +_OPEN_MODEL = "google/gemma3@gemma-3-12b-it" +_HF_MODEL = "Meta-Llama/Llama-3.3-70B-Instruct" # mixed case to exercise lowering + + +def _make_deploy_operation( + endpoint="projects/p/locations/us-central1/endpoints/123", + model="projects/p/locations/us-central1/models/456", +): + """Builds a fake DeployModelOperation for the private _deploy to return.""" + return types.DeployModelOperation( + response=types.DeployResponse(endpoint=endpoint, model=model), + ) + + +def _patch_deploy(mock_client, op=None): + """Patches mock_client._deploy with a MagicMock returning ``op``.""" + return mock.patch.object( + mock_client, "_deploy", return_value=op or _make_deploy_operation() + ) + + +def _patch_async_deploy(mock_client, op=None): + """Patches mock_async_client._deploy with an AsyncMock returning ``op``.""" + return mock.patch.object( + mock_client, + "_deploy", + new=mock.AsyncMock(return_value=op or _make_deploy_operation()), + ) + + +# --- Model-name resolution --------------------------------------------- + + +def test_deploy_publisher_model_simplified_name_reconciled(mock_client): + """Simplified '{publisher}/{model}@{version}' -> full resource name.""" + with _patch_deploy(mock_client) as mock_deploy: + mock_client.deploy_publisher_model(model=_OPEN_MODEL) + kwargs = mock_deploy.call_args.kwargs + assert ( + kwargs["publisher_model_name"] + == "publishers/google/models/gemma3@gemma-3-12b-it" + ) + assert kwargs["hugging_face_model_id"] is None + assert kwargs["destination"] == ( + f"projects/{_TEST_PROJECT}/locations/{_TEST_LOCATION}" + ) + + +def test_deploy_publisher_model_full_resource_name_preserved(mock_client): + """A full 'publishers/.../models/...@version' is passed through unchanged.""" + with _patch_deploy(mock_client) as mock_deploy: + mock_client.deploy_publisher_model( + model="publishers/google/models/gemma3@gemma-3-12b-it" + ) + assert ( + mock_deploy.call_args.kwargs["publisher_model_name"] + == "publishers/google/models/gemma3@gemma-3-12b-it" + ) + + +def test_deploy_publisher_model_hugging_face_uses_hf_model_id(mock_client): + """HF IDs are lowercased and routed to huggingFaceModelId (legacy parity).""" + with _patch_deploy(mock_client) as mock_deploy: + mock_client.deploy_publisher_model(model=_HF_MODEL) + kwargs = mock_deploy.call_args.kwargs + assert kwargs["publisher_model_name"] is None + assert kwargs["hugging_face_model_id"] == "meta-llama/llama-3.3-70b-instruct" + + +def test_deploy_publisher_model_invalid_name_raises_before_rpc(mock_client): + """Malformed model names raise ValueError; no RPC is issued.""" + with mock.patch.object(mock_client, "_deploy") as mock_deploy: + with pytest.raises(ValueError, match="not a valid publisher model name"): + mock_client.deploy_publisher_model(model="not-a-valid-name") + mock_deploy.assert_not_called() + + +# --- Config translation to DeployRequest sub-messages ------------------- + + +def test_deploy_publisher_model_default_config_no_optional_blocks(mock_client): + """Empty config -> no DedicatedResources, no ContainerSpec, no PSC.""" + with _patch_deploy(mock_client) as mock_deploy: + mock_client.deploy_publisher_model(model=_OPEN_MODEL) + kwargs = mock_deploy.call_args.kwargs + assert kwargs["deploy_config"].dedicated_resources is None + assert kwargs["model_config_val"].container_spec is None + assert kwargs["endpoint_config"].dedicated_endpoint_enabled is None + assert kwargs["endpoint_config"].private_service_connect_config is None + + +def test_deploy_publisher_model_dict_config_validated(mock_client): + """A plain dict is validated into DeployPublisherModelConfig.""" + with _patch_deploy(mock_client) as mock_deploy: + mock_client.deploy_publisher_model( + model=_OPEN_MODEL, + config={"machine_type": "g2-standard-12", "accept_eula": True}, + ) + kwargs = mock_deploy.call_args.kwargs + assert kwargs["model_config_val"].accept_eula is True + assert ( + kwargs["deploy_config"].dedicated_resources.machine_spec.machine_type + == "g2-standard-12" + ) + + +def test_deploy_publisher_model_machine_replica_and_spot_forwarded(mock_client): + """machine_type/accelerator/replicas/spot populate DedicatedResources.""" + with _patch_deploy(mock_client) as mock_deploy: + mock_client.deploy_publisher_model( + model=_OPEN_MODEL, + config=types.DeployPublisherModelConfig( + machine_type="g2-standard-12", + accelerator_type="NVIDIA_L4", + accelerator_count=1, + min_replica_count=2, + max_replica_count=5, + spot=True, + ), + ) + dr = mock_deploy.call_args.kwargs["deploy_config"].dedicated_resources + assert dr.machine_spec.machine_type == "g2-standard-12" + assert dr.machine_spec.accelerator_type == "NVIDIA_L4" + assert dr.machine_spec.accelerator_count == 1 + assert dr.min_replica_count == 2 + assert dr.max_replica_count == 5 + assert dr.spot is True + + +def test_deploy_publisher_model_accelerator_only_triggers_dedicated_resources( + mock_client, +): + """A single machine-shape field (accelerator_type) is enough to send DedicatedResources.""" + with _patch_deploy(mock_client) as mock_deploy: + mock_client.deploy_publisher_model( + model=_OPEN_MODEL, + config=types.DeployPublisherModelConfig(accelerator_type="NVIDIA_L4"), + ) + dr = mock_deploy.call_args.kwargs["deploy_config"].dedicated_resources + assert dr is not None + assert dr.machine_spec.accelerator_type == "NVIDIA_L4" + + +def test_deploy_publisher_model_dedicated_endpoint_disabled_inverts( + mock_client, +): + """dedicated_endpoint_disabled=True maps to dedicated_endpoint_enabled=False on wire.""" + with _patch_deploy(mock_client) as mock_deploy: + mock_client.deploy_publisher_model( + model=_OPEN_MODEL, + config=types.DeployPublisherModelConfig(dedicated_endpoint_disabled=True), + ) + assert ( + mock_deploy.call_args.kwargs["endpoint_config"].dedicated_endpoint_enabled + is False + ) + + +def test_deploy_publisher_model_dedicated_endpoint_disabled_false_maps_to_true( + mock_client, +): + """dedicated_endpoint_disabled=False maps to dedicated_endpoint_enabled=True.""" + with _patch_deploy(mock_client) as mock_deploy: + mock_client.deploy_publisher_model( + model=_OPEN_MODEL, + config=types.DeployPublisherModelConfig(dedicated_endpoint_disabled=False), + ) + assert ( + mock_deploy.call_args.kwargs["endpoint_config"].dedicated_endpoint_enabled + is True + ) + + +def test_deploy_publisher_model_private_service_connect(mock_client): + """PSC fields populate PrivateServiceConnectConfig.""" + with _patch_deploy(mock_client) as mock_deploy: + mock_client.deploy_publisher_model( + model=_OPEN_MODEL, + config=types.DeployPublisherModelConfig( + enable_private_service_connect=True, + psc_project_allow_list=["project-a", "project-b"], + ), + ) + psc = mock_deploy.call_args.kwargs[ + "endpoint_config" + ].private_service_connect_config + assert psc.enable_private_service_connect is True + assert psc.project_allowlist == ["project-a", "project-b"] + + +def test_deploy_publisher_model_private_service_connect_without_allowlist( + mock_client, +): + """PSC without an allowlist is allowed (backend applies its own default).""" + with _patch_deploy(mock_client) as mock_deploy: + mock_client.deploy_publisher_model( + model=_OPEN_MODEL, + config=types.DeployPublisherModelConfig( + enable_private_service_connect=True, + ), + ) + psc = mock_deploy.call_args.kwargs[ + "endpoint_config" + ].private_service_connect_config + assert psc.enable_private_service_connect is True + assert psc.project_allowlist is None + + +def test_deploy_publisher_model_custom_container_spec(mock_client): + """image_uri + command + args + variables all populate ModelContainerSpec.""" + with _patch_deploy(mock_client) as mock_deploy: + mock_client.deploy_publisher_model( + model=_OPEN_MODEL, + config=types.DeployPublisherModelConfig( + serving_container_image_uri="us-docker.pkg.dev/vertex-ai/vllm", + container_command=["python", "-m", "vllm.entrypoints.api_server"], + container_args=["--tensor-parallel-size=2"], + container_variables={"MODEL_ID": "gemma-3-12b-it"}, + ), + ) + spec = mock_deploy.call_args.kwargs["model_config_val"].container_spec + assert spec.image_uri == "us-docker.pkg.dev/vertex-ai/vllm" + assert spec.command == ["python", "-m", "vllm.entrypoints.api_server"] + assert spec.args == ["--tensor-parallel-size=2"] + assert spec.env == [types.EnvVar(name="MODEL_ID", value="gemma-3-12b-it")] + + +def test_deploy_publisher_model_container_image_without_variables_leaves_env_none( + mock_client, +): + """serving_container_image_uri alone -> container_spec set, env stays None.""" + with _patch_deploy(mock_client) as mock_deploy: + mock_client.deploy_publisher_model( + model=_OPEN_MODEL, + config=types.DeployPublisherModelConfig( + serving_container_image_uri="us-docker.pkg.dev/vertex-ai/vllm", + ), + ) + spec = mock_deploy.call_args.kwargs["model_config_val"].container_spec + assert spec.image_uri == "us-docker.pkg.dev/vertex-ai/vllm" + assert spec.env is None + + +def test_deploy_publisher_model_container_variables_without_image_ignored( + mock_client, +): + """container_variables without serving_container_image_uri -> no container_spec.""" + with _patch_deploy(mock_client) as mock_deploy: + mock_client.deploy_publisher_model( + model=_OPEN_MODEL, + config=types.DeployPublisherModelConfig( + container_variables={"FOO": "bar"}, + ), + ) + assert mock_deploy.call_args.kwargs["model_config_val"].container_spec is None + + +def test_deploy_publisher_model_display_names_and_fast_tryout(mock_client): + """endpoint/model display names and fast_tryout_enabled land on the right sub-messages.""" + with _patch_deploy(mock_client) as mock_deploy: + mock_client.deploy_publisher_model( + model=_OPEN_MODEL, + config=types.DeployPublisherModelConfig( + endpoint_display_name="my-endpoint", + model_display_name="my-model", + fast_tryout_enabled=True, + ), + ) + kwargs = mock_deploy.call_args.kwargs + assert kwargs["endpoint_config"].endpoint_display_name == "my-endpoint" + assert kwargs["model_config_val"].model_display_name == "my-model" + assert kwargs["deploy_config"].fast_tryout_enabled is True + + +def test_deploy_publisher_model_forwards_eula_and_hf_token(mock_client): + """accept_eula and hugging_face_access_token land on model_config.""" + with _patch_deploy(mock_client) as mock_deploy: + mock_client.deploy_publisher_model( + model=_HF_MODEL, + config=types.DeployPublisherModelConfig( + accept_eula=True, + hugging_face_access_token="hf_token_xyz", + ), + ) + model_config = mock_deploy.call_args.kwargs["model_config_val"] + assert model_config.accept_eula is True + assert model_config.hugging_face_access_token == "hf_token_xyz" + + +def test_deploy_publisher_model_returns_operation(mock_client): + """_deploy's return value is returned unchanged (with endpoint/model in response).""" + op = _make_deploy_operation( + endpoint="projects/p/locations/us-central1/endpoints/e-1", + model="projects/p/locations/us-central1/models/m-1", + ) + with _patch_deploy(mock_client, op=op): + result = mock_client.deploy_publisher_model(model=_OPEN_MODEL) + assert result is op + assert ( + result.response.endpoint == "projects/p/locations/us-central1/endpoints/e-1" + ) + assert result.response.model == "projects/p/locations/us-central1/models/m-1" + + +# --- Async surface ----------------------------------------------------- + + +def test_deploy_publisher_model_async_publisher(mock_async_client): + """AsyncModelGarden.deploy_publisher_model routes to _deploy identically.""" + with _patch_async_deploy(mock_async_client) as mock_deploy: + result = asyncio.run( + mock_async_client.deploy_publisher_model( + model=_OPEN_MODEL, + config=types.DeployPublisherModelConfig(machine_type="g2-standard-12"), + ) + ) + kwargs = mock_deploy.call_args.kwargs + assert isinstance(result, types.DeployModelOperation) + assert ( + kwargs["publisher_model_name"] + == "publishers/google/models/gemma3@gemma-3-12b-it" + ) + assert ( + kwargs["deploy_config"].dedicated_resources.machine_spec.machine_type + == "g2-standard-12" + ) + + +def test_deploy_publisher_model_async_hugging_face(mock_async_client): + """Async HF path also lowercases and routes to hugging_face_model_id.""" + with _patch_async_deploy(mock_async_client) as mock_deploy: + asyncio.run(mock_async_client.deploy_publisher_model(model=_HF_MODEL)) + kwargs = mock_deploy.call_args.kwargs + assert kwargs["publisher_model_name"] is None + assert kwargs["hugging_face_model_id"] == "meta-llama/llama-3.3-70b-instruct" + + +def test_deploy_publisher_model_async_invalid_name_raises(mock_async_client): + """Async invalid model name raises ValueError before any RPC.""" + with mock.patch.object(mock_async_client, "_deploy") as mock_deploy: + with pytest.raises(ValueError, match="not a valid publisher model name"): + asyncio.run( + mock_async_client.deploy_publisher_model(model="not-a-valid-name") + ) + mock_deploy.assert_not_called()