diff --git a/sdk/ai/azure-ai-projects/CHANGELOG.md b/sdk/ai/azure-ai-projects/CHANGELOG.md index 521d66eda24f..a2ab3329ef5b 100644 --- a/sdk/ai/azure-ai-projects/CHANGELOG.md +++ b/sdk/ai/azure-ai-projects/CHANGELOG.md @@ -20,7 +20,7 @@ * New optional `force` parameter on `agents.delete` and `agents.delete_version` methods. * New optional `blueprint_reference` parameters on `agents.create_version` method. * New sample `sample_dataset_generation_job_simpleqna_with_prompt_source.py` showing an end-to-end flow that generates a QnA dataset via `.beta.datasets.create_generation_job` and runs an OpenAI evaluation. - +* New convenience method `.beta.models.create()` that wraps the spec's three-step upload-first sequence (`pending_upload` → `azcopy copy` → `pending_create_version`) and polls `get()` until the new `ModelVersion` is observable. ### Breaking Changes @@ -52,6 +52,10 @@ Breaking changes in beta classes: * The Hosted Agent creation sample also demonstrates assigning the hosted agent managed identity the Azure AI User RBAC role on the backing Azure AI account. * Updated the other Hosted Agent samples to reuse an existing Hosted Agent as a prerequisite, instead of creating a new hosted agent version in each sample. * Added Toolbox tool-search sample `sample_toolboxes_with_search_preview.py` and `sample_toolboxes_with_search_preview_async.py`, demonstrating creating a Toolbox version with `ToolboxSearchPreviewTool` and invoking `MCPTool`. +* Added `.beta.models` samples under `samples/models/`: + * `sample_models_basic.py` — synchronous end-to-end registration via the `create` helper (uses `azcopy`), followed by `get`, `list_versions`, `list`, `get_credentials`, `update`, and `delete`. + * `sample_models_without_patch.py` — alternative synchronous registration that hand-rolls the spec's three-step flow (`pending_upload` → upload via `azure-storage-blob` → `pending_create_version` + poll), without taking a dependency on `azcopy`. + * `sample_models_basic_async.py` — asynchronous version of the same three-step flow using `azure.ai.projects.aio.AIProjectClient` and `azure.storage.blob.aio.ContainerClient`. ## 2.1.0 (2026-04-20) diff --git a/sdk/ai/azure-ai-projects/README.md b/sdk/ai/azure-ai-projects/README.md index 39d57c2b21f3..44365a930a5f 100644 --- a/sdk/ai/azure-ai-projects/README.md +++ b/sdk/ai/azure-ai-projects/README.md @@ -35,6 +35,7 @@ resources in your Microsoft Foundry Project. Use it to: * **Enumerate AI Models** deployed to your Foundry Project using `.deployments` operations. * **Enumerate connected Azure resources** in your Foundry project using `.connections` operations. * **Upload documents and create Datasets** to reference them using `.datasets` operations. +* **Register and manage local model weights** as Foundry `ModelVersion` resources using `.beta.models` operations, including the `create` end-to-end helper. * **Create and enumerate Search Indexes** using `.indexes` operations. The client library uses version `v1` of the Microsoft Foundry [data plane REST APIs](https://aka.ms/azsdk/azure-ai-projects-v2/api-reference-v1). @@ -166,6 +167,7 @@ Full descriptions and working code for all of the above are available in: | Deployments | [Deployment types](https://learn.microsoft.com/azure/foundry/foundry-models/concepts/deployment-types) | `samples/deployments/` | | Connections | [Connections operations](https://learn.microsoft.com/python/api/overview/azure/ai-projects-readme?view=azure-python#connections-operations) | `samples/connections/` | | Datasets | [Dataset operations](https://learn.microsoft.com/python/api/overview/azure/ai-projects-readme?view=azure-python#dataset-operations) | `samples/datasets/` | +| Models (preview) | Register local model weights as Foundry `ModelVersion` resources via `.beta.models` (`create`, `list`, `list_versions`, `get`, `update`, `delete`, `pending_upload`, `pending_create_version`, `get_credentials`). | `samples/models/` | | Indexes | [Azure AI Search](https://learn.microsoft.com/azure/search/search-what-is-azure-search) | `samples/indexes/` | | Files (upload, retrieve, list, delete) | [OpenAI Files API](https://platform.openai.com/docs/api-reference/files) | `samples/files/` | | Fine-tuning | [Fine-Tuning in AI Foundry](https://github.com/microsoft-foundry/fine-tuning) | `samples/finetuning/` | diff --git a/sdk/ai/azure-ai-projects/assets.json b/sdk/ai/azure-ai-projects/assets.json index 6ba48111cd83..8ef395189e25 100644 --- a/sdk/ai/azure-ai-projects/assets.json +++ b/sdk/ai/azure-ai-projects/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "python", "TagPrefix": "python/ai/azure-ai-projects", - "Tag": "python/ai/azure-ai-projects_b13a910d61" + "Tag": "python/ai/azure-ai-projects_f04966e97c" } diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch.py b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch.py index c9b8cfff7731..9840dd5a6ac6 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch.py @@ -15,6 +15,7 @@ from ._patch_telemetry_async import TelemetryOperations from ._patch_connections_async import ConnectionsOperations from ._patch_memories_async import BetaMemoryStoresOperations +from ._patch_models_async import BetaModelsOperations from ._patch_sessions_async import BetaAgentsOperations from ...operations._patch import _BETA_OPERATION_FEATURE_HEADERS, _OperationMethodHeaderProxy from ._operations import ( @@ -22,7 +23,6 @@ BetaEvaluationTaxonomiesOperations, BetaEvaluatorsOperations, BetaInsightsOperations, - BetaModelsOperations, BetaOperations as GeneratedBetaOperations, BetaRedTeamsOperations, BetaRoutinesOperations, @@ -75,6 +75,8 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.agents = BetaAgentsOperations(self._client, self._config, self._serialize, self._deserialize) # Replace with patched class that includes begin_update_memories self.memory_stores = BetaMemoryStoresOperations(self._client, self._config, self._serialize, self._deserialize) + # Replace with patched class that includes create (3-step upload helper) + self.models = BetaModelsOperations(self._client, self._config, self._serialize, self._deserialize) for property_name, foundry_features_value in _BETA_OPERATION_FEATURE_HEADERS.items(): setattr( diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch_models_async.py b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch_models_async.py new file mode 100644 index 000000000000..c28382735599 --- /dev/null +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch_models_async.py @@ -0,0 +1,295 @@ +# pylint: disable=line-too-long,useless-suppression +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +"""Customize generated code here. + +Follow our quickstart for examples: https://aka.ms/azsdk/python/dpcodegen/python/customize +""" + +import asyncio # pylint: disable=do-not-import-asyncio +import logging +import os +from pathlib import Path +from typing import Any, Optional, Union + +from azure.core.exceptions import ResourceNotFoundError +from azure.core.tracing.decorator_async import distributed_trace_async + +from ._operations import BetaModelsOperations as BetaModelsOperationsGenerated +from ...models._models import ( + ModelPendingUploadRequest, + ModelPendingUploadResponse, + ModelVersion, + PendingUploadType, +) + +logger = logging.getLogger(__name__) + + +class BetaModelsOperations(BetaModelsOperationsGenerated): + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~azure.ai.projects.aio.AIProjectClient`'s + :attr:`beta.models ` attribute. + """ + + @staticmethod + def _extract_pending_upload_targets( + response: Union[ModelPendingUploadResponse, dict], + ) -> "tuple[str, str, Optional[str]]": + """Return ``(sas_uri, container_blob_uri, pending_upload_id)`` from a pending-upload response. + + The service currently returns the raw datastore-style payload + (``blobReferenceForConsumption`` / ``temporaryDataReferenceId``) for some + Foundry deployments rather than the SDK-modeled ``ModelPendingUploadResponse`` + shape (``blobReference`` / ``pendingUploadId``). Tolerate both wire + shapes so callers don't have to. + + :param response: The pending-upload response from the service. + :type response: ~azure.ai.projects.models.ModelPendingUploadResponse or dict + :return: A tuple of ``(sas_uri, container_blob_uri, pending_upload_id)``. + :rtype: tuple[str, str, str or None] + """ + payload = dict(response) if isinstance(response, dict) else response.as_dict() + + blob_ref = payload.get("blobReferenceForConsumption") or payload.get("blobReference") or {} + sas_uri = (blob_ref.get("credential") or {}).get("sasUri") + container_blob_uri = blob_ref.get("blobUri") + pending_upload_id = payload.get("temporaryDataReferenceId") or payload.get("pendingUploadId") + + if not sas_uri or not container_blob_uri: + raise ValueError("Could not locate SAS URI / blob URI in pending_upload response: " f"{payload!r}") + return sas_uri, container_blob_uri, pending_upload_id + + @staticmethod + def _validate_create_inputs( + *, + name: str, + version: str, + source: Union[str, "os.PathLike[str]"], + wait_for_commit: bool, + polling_timeout: float, + polling_interval: float, + ) -> Path: + """Validate ``create`` inputs up-front, before any service call. + + Returns the resolved ``Path`` for ``source``. Raises ``ValueError`` for + bad inputs. + + :keyword name: Name of the model to register. + :paramtype name: str + :keyword version: Version identifier for the model. + :paramtype version: str + :keyword source: Local file or directory containing the model weights. + :paramtype source: str or os.PathLike[str] + :keyword wait_for_commit: Whether to poll for commit completion. + :paramtype wait_for_commit: bool + :keyword polling_timeout: Total seconds to poll for commit completion. + :paramtype polling_timeout: float + :keyword polling_interval: Seconds between poll attempts. + :paramtype polling_interval: float + :return: The resolved ``Path`` for ``source``. + :rtype: pathlib.Path + """ + if not isinstance(name, str) or not name.strip(): + raise ValueError("`name` must be a non-empty string.") + if not isinstance(version, str) or not version.strip(): + raise ValueError("`version` must be a non-empty string.") + + source_path = Path(os.fspath(source)) + if not source_path.exists(): + raise ValueError(f"Upload source does not exist: {source_path}") + if source_path.is_dir() and not any(p.is_file() for p in source_path.rglob("*")): + raise ValueError(f"Upload source directory is empty: {source_path}") + if source_path.is_file() and source_path.stat().st_size == 0: + raise ValueError(f"Upload source file is empty: {source_path}") + + if wait_for_commit: + if polling_timeout <= 0: + raise ValueError("`polling_timeout` must be > 0 when `wait_for_commit` is True.") + if polling_interval <= 0: + raise ValueError("`polling_interval` must be > 0 when `wait_for_commit` is True.") + + return source_path + + @staticmethod + async def _upload_with_container_client(source: Path, sas_uri: str) -> None: + """Upload ``source`` to the SAS container using ``azure.storage.blob.aio.ContainerClient``. + + :param source: Local file or directory to upload. + :type source: pathlib.Path + :param sas_uri: SAS URI for the destination container. + :type sas_uri: str + :raises RuntimeError: If ``azure-storage-blob`` is not installed. + """ + try: + from azure.storage.blob.aio import ContainerClient # pylint: disable=import-outside-toplevel + except ImportError as ex: + raise RuntimeError( + "`azure-storage-blob` is required for the async `create` helper. " + "Install it with `pip install azure-storage-blob aiohttp`." + ) from ex + + if source.is_dir(): + files = [p for p in source.rglob("*") if p.is_file()] + if not files: + raise ValueError(f"Upload source directory is empty: {source}") + elif source.is_file(): + files = [source] + else: + raise ValueError(f"Upload source does not exist: {source}") + + # Don't log the SAS query string — it's a credential. + redacted = sas_uri.split("?", 1)[0] + "?" + logger.info("[create] uploading %d file(s) to %s", len(files), redacted) + + async with ContainerClient.from_container_url(sas_uri) as container_client: + for f in files: + rel = f.relative_to(source).as_posix() if source.is_dir() else f.name + with f.open("rb") as fp: + await container_client.upload_blob(name=rel, data=fp, overwrite=True) + logger.debug("[create] uploaded %s (%d bytes)", rel, f.stat().st_size) + + @distributed_trace_async + async def create( + self, + *, + name: str, + version: str, + source: Union[str, "os.PathLike[str]"], + weight_type: Optional[str] = None, + base_model: Optional[str] = None, + description: Optional[str] = None, + tags: Optional["dict[str, str]"] = None, + wait_for_commit: bool = True, + polling_timeout: float = 300.0, + polling_interval: float = 2.0, + **kwargs: Any, + ) -> Optional[ModelVersion]: + """Register a local model by running the full upload-first sequence (async). + + This wraps the three mandatory steps of the model-registration spec + into a single call: + + 1. :meth:`pending_upload` — provision a project-managed blob container + and obtain a SAS URI. + 2. Upload the local weight files to the SAS container using + :class:`azure.storage.blob.aio.ContainerClient`. + 3. :meth:`pending_create_version` — finalize registration with the + ``ModelVersion`` body (``blob_uri``, ``weight_type``, ``base_model``, + ``description``, ``tags``). + + Requires the ``azure-storage-blob`` package (with ``aiohttp``) for the + upload step. + + :keyword name: Name of the model to register. Required. + :paramtype name: str + :keyword version: Version identifier for the model. Required. + :paramtype version: str + :keyword source: Local file or directory containing the model weights. + If a directory, its contents are uploaded recursively to the SAS + container root. Required. + :paramtype source: str or os.PathLike[str] + :keyword weight_type: Optional weight type (e.g. ``"FullWeight"``, + ``"LoRA"``, ``"DraftModel"``). + :paramtype weight_type: str + :keyword base_model: Optional base model asset ID. + :paramtype base_model: str + :keyword description: Optional asset description. + :paramtype description: str + :keyword tags: Optional asset tags. + :paramtype tags: dict[str, str] + :keyword wait_for_commit: When True (default) poll :meth:`get` until + the committed ``ModelVersion`` is observable, and return it. + When False, return ``None`` after the async commit is accepted. + :paramtype wait_for_commit: bool + :keyword polling_timeout: Total seconds to poll for commit completion. + :paramtype polling_timeout: float + :keyword polling_interval: Seconds between poll attempts. + :paramtype polling_interval: float + :return: The committed :class:`~azure.ai.projects.models.ModelVersion` + when ``wait_for_commit`` is True, otherwise ``None``. + :rtype: ~azure.ai.projects.models.ModelVersion or None + :raises ValueError: If ``name``/``version`` are empty, ``source`` does + not exist or is empty, polling parameters are non-positive, or the + pending-upload response is missing the SAS / blob URI. + :raises RuntimeError: If ``azure-storage-blob`` is not installed or + the registration does not commit before ``polling_timeout`` elapses. + """ + # --- Step 0: validate inputs up-front -------------------------------- + source_path = self._validate_create_inputs( + name=name, + version=version, + source=source, + wait_for_commit=wait_for_commit, + polling_timeout=polling_timeout, + polling_interval=polling_interval, + ) + + # --- Step 1: StartPendingUpload -------------------------------------- + logger.info( + "[create] step 1/3 pending_upload(name=%r, version=%r)", + name, + version, + ) + pending = await self.pending_upload( + name=name, + version=version, + pending_upload_request=ModelPendingUploadRequest( + pending_upload_type=PendingUploadType.TEMPORARY_BLOB_REFERENCE, + ), + **kwargs, + ) + sas_uri, container_blob_uri, pending_upload_id = self._extract_pending_upload_targets(pending) + logger.info( + "[create] pending_upload_id=%s blob_uri=%s", + pending_upload_id, + container_blob_uri, + ) + + # --- Step 2: Upload via async ContainerClient ------------------------ + logger.info("[create] step 2/3 async upload from %s", source_path) + await self._upload_with_container_client(source_path, sas_uri) + + # --- Step 3: Commit registration ------------------------------------- + model_version_body = ModelVersion( + blob_uri=container_blob_uri, + weight_type=weight_type, + base_model=base_model, + description=description, + tags=tags or {}, + ) + logger.info( + "[create] step 3/3 pending_create_version(name=%r, version=%r)", + name, + version, + ) + await self.pending_create_version(name=name, version=version, model_version=model_version_body, **kwargs) + + if not wait_for_commit: + return None + + # The async op returns 202; the service materializes the ModelVersion + # asynchronously. Poll get() until it appears or we time out. + import time # pylint: disable=import-outside-toplevel + + deadline = time.monotonic() + polling_timeout + last_exc: Optional[BaseException] = None + while True: + try: + return await self.get(name=name, version=version, **kwargs) + except ResourceNotFoundError as ex: + last_exc = ex + if time.monotonic() >= deadline: + raise RuntimeError( + f"Model {name!r}@{version!r} did not appear within " f"{polling_timeout}s after pending_create_version." + ) from last_exc + await asyncio.sleep(polling_interval) + + +__all__ = ["BetaModelsOperations"] diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch.py b/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch.py index 88ce76ca765a..29adf947b535 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch.py @@ -18,13 +18,13 @@ from ._patch_telemetry import TelemetryOperations from ._patch_connections import ConnectionsOperations from ._patch_memories import BetaMemoryStoresOperations +from ._patch_models import BetaModelsOperations from ._patch_sessions import BetaAgentsOperations from ._operations import ( BetaDatasetsOperations, BetaEvaluationTaxonomiesOperations, BetaEvaluatorsOperations, BetaInsightsOperations, - BetaModelsOperations, BetaOperations as GeneratedBetaOperations, BetaRedTeamsOperations, BetaRoutinesOperations, @@ -130,6 +130,8 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.agents = BetaAgentsOperations(self._client, self._config, self._serialize, self._deserialize) # Replace with patched class that includes begin_update_memories self.memory_stores = BetaMemoryStoresOperations(self._client, self._config, self._serialize, self._deserialize) + # Replace with patched class that includes create (3-step upload helper) + self.models = BetaModelsOperations(self._client, self._config, self._serialize, self._deserialize) for property_name, foundry_features_value in _BETA_OPERATION_FEATURE_HEADERS.items(): setattr( diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch_models.py b/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch_models.py new file mode 100644 index 000000000000..e123b943af29 --- /dev/null +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch_models.py @@ -0,0 +1,332 @@ +# pylint: disable=line-too-long,useless-suppression +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +"""Customize generated code here. + +Follow our quickstart for examples: https://aka.ms/azsdk/python/dpcodegen/python/customize +""" + +import logging +import os +import shutil +import subprocess +import time +from pathlib import Path +from typing import Any, Optional, Union + +from azure.core.exceptions import ResourceNotFoundError +from azure.core.tracing.decorator import distributed_trace + +from ._operations import BetaModelsOperations as BetaModelsOperationsGenerated +from ..models._models import ( + ModelPendingUploadRequest, + ModelPendingUploadResponse, + ModelVersion, + PendingUploadType, +) + +logger = logging.getLogger(__name__) + + +class BetaModelsOperations(BetaModelsOperationsGenerated): + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~azure.ai.projects.AIProjectClient`'s + :attr:`beta.models ` attribute. + """ + + @staticmethod + def _extract_pending_upload_targets( + response: Union[ModelPendingUploadResponse, dict], + ) -> "tuple[str, str, Optional[str]]": + """Return ``(sas_uri, container_blob_uri, pending_upload_id)`` from a pending-upload response. + + The service currently returns the raw datastore-style payload + (``blobReferenceForConsumption`` / ``temporaryDataReferenceId``) for some + Foundry deployments rather than the SDK-modeled ``ModelPendingUploadResponse`` + shape (``blobReference`` / ``pendingUploadId``). Tolerate both wire + shapes so callers don't have to. + + :param response: The pending-upload response from the service. + :type response: ~azure.ai.projects.models.ModelPendingUploadResponse or dict + :return: A tuple of ``(sas_uri, container_blob_uri, pending_upload_id)``. + :rtype: tuple[str, str, str or None] + """ + payload = dict(response) if isinstance(response, dict) else response.as_dict() + + blob_ref = payload.get("blobReferenceForConsumption") or payload.get("blobReference") or {} + sas_uri = (blob_ref.get("credential") or {}).get("sasUri") + container_blob_uri = blob_ref.get("blobUri") + pending_upload_id = payload.get("temporaryDataReferenceId") or payload.get("pendingUploadId") + + if not sas_uri or not container_blob_uri: + raise ValueError("Could not locate SAS URI / blob URI in pending_upload response: " f"{payload!r}") + return sas_uri, container_blob_uri, pending_upload_id + + @staticmethod + def _resolve_azcopy(azcopy_path: Optional[str] = None) -> str: + """Locate the ``azcopy`` executable or raise ``RuntimeError``. + + :param azcopy_path: Optional explicit path to the azcopy executable. + Defaults to ``shutil.which("azcopy")``. + :type azcopy_path: str or None + :return: Absolute path to the resolved azcopy executable. + :rtype: str + """ + azcopy = azcopy_path or shutil.which("azcopy") + if not azcopy: + raise RuntimeError( + "`azcopy` was not found on PATH. Install AzCopy " + "(https://aka.ms/downloadazcopy) and ensure it is on PATH, or " + "pass `azcopy_path=` explicitly." + ) + return azcopy + + @staticmethod + def _validate_create_inputs( + *, + name: str, + version: str, + source: Union[str, "os.PathLike[str]"], + azcopy_path: Optional[str], + wait_for_commit: bool, + polling_timeout: float, + polling_interval: float, + ) -> Path: + """Validate ``create`` inputs up-front, before any service call. + + Returns the resolved ``Path`` for ``source``. Raises ``ValueError`` for + bad inputs and ``RuntimeError`` if ``azcopy`` cannot be located. + + :keyword name: Name of the model to register. + :paramtype name: str + :keyword version: Version identifier for the model. + :paramtype version: str + :keyword source: Local file or directory containing the model weights. + :paramtype source: str or os.PathLike[str] + :keyword azcopy_path: Optional explicit path to the azcopy executable. + :paramtype azcopy_path: str or None + :keyword wait_for_commit: Whether to poll for commit completion. + :paramtype wait_for_commit: bool + :keyword polling_timeout: Total seconds to poll for commit completion. + :paramtype polling_timeout: float + :keyword polling_interval: Seconds between poll attempts. + :paramtype polling_interval: float + :return: The resolved ``Path`` for ``source``. + :rtype: pathlib.Path + """ + if not isinstance(name, str) or not name.strip(): + raise ValueError("`name` must be a non-empty string.") + if not isinstance(version, str) or not version.strip(): + raise ValueError("`version` must be a non-empty string.") + + source_path = Path(os.fspath(source)) + if not source_path.exists(): + raise ValueError(f"Upload source does not exist: {source_path}") + if source_path.is_dir() and not any(p.is_file() for p in source_path.rglob("*")): + raise ValueError(f"Upload source directory is empty: {source_path}") + if source_path.is_file() and source_path.stat().st_size == 0: + raise ValueError(f"Upload source file is empty: {source_path}") + + if wait_for_commit: + if polling_timeout <= 0: + raise ValueError("`polling_timeout` must be > 0 when `wait_for_commit` is True.") + if polling_interval <= 0: + raise ValueError("`polling_interval` must be > 0 when `wait_for_commit` is True.") + + # Fail fast if azcopy isn't installed, before we provision a SAS container. + BetaModelsOperations._resolve_azcopy(azcopy_path) + return source_path + + @staticmethod + def _run_azcopy(source: Path, sas_uri: str, *, azcopy_path: Optional[str] = None) -> None: + """Shell out to ``azcopy copy`` to upload ``source`` to the SAS container. + + :param source: Local file or directory to upload. + :type source: pathlib.Path + :param sas_uri: SAS URI for the destination container. + :type sas_uri: str + :keyword azcopy_path: Optional explicit path to the azcopy executable. + :paramtype azcopy_path: str or None + """ + azcopy = BetaModelsOperations._resolve_azcopy(azcopy_path) + + if source.is_dir(): + src_arg = str(source / "*") + elif source.is_file(): + src_arg = str(source) + else: + raise ValueError(f"Upload source does not exist: {source}") + + cmd = [ + azcopy, + "copy", + src_arg, + sas_uri, + "--from-to", + "LocalBlob", + "--recursive", + ] + + # Don't log the SAS query string — it's a credential. + redacted = cmd.copy() + redacted[3] = sas_uri.split("?", 1)[0] + "?" + logger.info("[create] running: %s", " ".join(redacted)) + + completed = subprocess.run(cmd, check=False, capture_output=True, text=True) + if completed.stdout: + logger.debug("[create] azcopy stdout:\n%s", completed.stdout) + if completed.stderr: + logger.debug("[create] azcopy stderr:\n%s", completed.stderr) + if completed.returncode != 0: + raise RuntimeError( + f"azcopy exited with code {completed.returncode}.\n" + f"stdout:\n{completed.stdout}\nstderr:\n{completed.stderr}" + ) + + @distributed_trace + def create( + self, + *, + name: str, + version: str, + source: Union[str, "os.PathLike[str]"], + weight_type: Optional[str] = None, + base_model: Optional[str] = None, + description: Optional[str] = None, + tags: Optional["dict[str, str]"] = None, + azcopy_path: Optional[str] = None, + wait_for_commit: bool = True, + polling_timeout: float = 300.0, + polling_interval: float = 2.0, + **kwargs: Any, + ) -> Optional[ModelVersion]: + """Register a local model by running the full upload-first sequence. + + This wraps the three mandatory steps of the model-registration spec + into a single call: + + 1. :meth:`pending_upload` — provision a project-managed blob container + and obtain a SAS URI. + 2. ``azcopy copy`` — upload the local weight files directly to the + SAS container. + 3. :meth:`pending_create_version` — finalize registration with the + ``ModelVersion`` body (``blob_uri``, ``weight_type``, ``base_model``, + ``description``, ``tags``). + + :keyword name: Name of the model to register. Required. + :paramtype name: str + :keyword version: Version identifier for the model. Required. + :paramtype version: str + :keyword source: Local file or directory containing the model weights. + If a directory, its contents are uploaded recursively to the SAS + container root. Required. + :paramtype source: str or os.PathLike[str] + :keyword weight_type: Optional weight type (e.g. ``"FullWeight"``, + ``"LoRA"``, ``"DraftModel"``). + :paramtype weight_type: str + :keyword base_model: Optional base model asset ID. + :paramtype base_model: str + :keyword description: Optional asset description. + :paramtype description: str + :keyword tags: Optional asset tags. + :paramtype tags: dict[str, str] + :keyword azcopy_path: Optional explicit path to the azcopy executable. + Defaults to ``shutil.which("azcopy")``. + :paramtype azcopy_path: str + :keyword wait_for_commit: When True (default) poll :meth:`get` until + the committed ``ModelVersion`` is observable, and return it. + When False, return ``None`` after the async commit is accepted. + :paramtype wait_for_commit: bool + :keyword polling_timeout: Total seconds to poll for commit completion. + :paramtype polling_timeout: float + :keyword polling_interval: Seconds between poll attempts. + :paramtype polling_interval: float + :return: The committed :class:`~azure.ai.projects.models.ModelVersion` + when ``wait_for_commit`` is True, otherwise ``None``. + :rtype: ~azure.ai.projects.models.ModelVersion or None + :raises ValueError: If ``name``/``version`` are empty, ``source`` does + not exist or is empty, polling parameters are non-positive, or the + pending-upload response is missing the SAS / blob URI. + :raises RuntimeError: If ``azcopy`` is not on PATH or exits with a + non-zero status, or the registration does not commit before + ``polling_timeout`` elapses. + """ + # --- Step 0: validate inputs up-front -------------------------------- + # Cheap local checks so we don't provision a SAS container or run + # azcopy when something obviously wrong was passed in. + source_path = self._validate_create_inputs( + name=name, + version=version, + source=source, + azcopy_path=azcopy_path, + wait_for_commit=wait_for_commit, + polling_timeout=polling_timeout, + polling_interval=polling_interval, + ) + + # --- Step 1: StartPendingUpload -------------------------------------- + logger.info( + "[create] step 1/3 pending_upload(name=%r, version=%r)", + name, + version, + ) + pending = self.pending_upload( + name=name, + version=version, + pending_upload_request=ModelPendingUploadRequest( + pending_upload_type=PendingUploadType.TEMPORARY_BLOB_REFERENCE, + ), + **kwargs, + ) + sas_uri, container_blob_uri, pending_upload_id = self._extract_pending_upload_targets(pending) + logger.info( + "[create] pending_upload_id=%s blob_uri=%s", + pending_upload_id, + container_blob_uri, + ) + + # --- Step 2: Upload via azcopy --------------------------------------- + logger.info("[create] step 2/3 azcopy upload from %s", source_path) + self._run_azcopy(source_path, sas_uri, azcopy_path=azcopy_path) + + # --- Step 3: Commit registration ------------------------------------- + model_version_body = ModelVersion( + blob_uri=container_blob_uri, + weight_type=weight_type, + base_model=base_model, + description=description, + tags=tags or {}, + ) + logger.info( + "[create] step 3/3 pending_create_version(name=%r, version=%r)", + name, + version, + ) + self.pending_create_version(name=name, version=version, model_version=model_version_body, **kwargs) + + if not wait_for_commit: + return None + + # The async op returns 202; the service materializes the ModelVersion + # asynchronously. Poll get() until it appears or we time out. + deadline = time.monotonic() + polling_timeout + last_exc: Optional[BaseException] = None + while True: + try: + return self.get(name=name, version=version, **kwargs) + except ResourceNotFoundError as ex: + last_exc = ex + if time.monotonic() >= deadline: + raise RuntimeError( + f"Model {name!r}@{version!r} did not appear within " f"{polling_timeout}s after pending_create_version." + ) from last_exc + time.sleep(polling_interval) + + +__all__ = ["BetaModelsOperations"] diff --git a/sdk/ai/azure-ai-projects/cspell.json b/sdk/ai/azure-ai-projects/cspell.json index 7decf206d14a..dd14b6b68b0e 100644 --- a/sdk/ai/azure-ai-projects/cspell.json +++ b/sdk/ai/azure-ai-projects/cspell.json @@ -6,6 +6,8 @@ "aiservices", "ansii", "aread", + "azcopy", + "Azcopy", "azureai", "azureopenai", "balapvbyostoragecanary", @@ -28,7 +30,10 @@ "Ministral", "mpkjc", "quantitive", + "recsmplmdl", "reraises", + "simpleqna", + "skoid", "Tadmaq", "Udbk", "UPIA", diff --git a/sdk/ai/azure-ai-projects/samples/models/sample_models_basic.py b/sdk/ai/azure-ai-projects/samples/models/sample_models_basic.py new file mode 100644 index 000000000000..badcdcf4c40b --- /dev/null +++ b/sdk/ai/azure-ai-projects/samples/models/sample_models_basic.py @@ -0,0 +1,128 @@ +# pylint: disable=line-too-long,useless-suppression +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ + +""" +DESCRIPTION: + Given an AIProjectClient, this sample demonstrates how to use the synchronous + `.beta.models` operations to register a local model with a Microsoft Foundry + project, list and inspect model versions, retrieve storage credentials, + update version metadata, and delete a model version. + + The recommended entry point is the patched helper + `project_client.beta.models.create(...)`, which packs the spec's + three required steps (`pending_upload` -> `azcopy copy` -> `pending_create_version`) + into a single call and polls until the new ModelVersion is observable. + +USAGE: + python sample_models_basic.py + + Before running the sample: + + pip install "azure-ai-projects>=2.2.0" azure-identity python-dotenv + + AzCopy must also be installed and on PATH (used by `create` to + upload weight files): + + winget install --id Microsoft.Azure.AZCopy.10 -e + + See https://aka.ms/downloadazcopy for other platforms. + + Set these environment variables with your own values: + 1) FOUNDRY_PROJECT_ENDPOINT - Required. The Azure AI Project endpoint, as + found in the overview page of your Microsoft Foundry project. + 2) MODEL_NAME - Optional. The name of the model to register. Defaults to + "sample-model". + 3) MODEL_VERSION - Optional. The version of the model to register. + Defaults to "1". + 4) DATA_FOLDER - Optional. The folder containing the local weight files + to upload. Defaults to a temp folder created with two tiny dummy files. +""" + +import os +import pathlib +import tempfile + +from dotenv import load_dotenv + +from azure.identity import DefaultAzureCredential + +from azure.ai.projects import AIProjectClient +from azure.ai.projects.models import ( + FoundryModelWeightType, + ModelCredentialRequest, + UpdateModelVersionRequest, +) + +load_dotenv() + +endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] +model_name = os.environ.get("MODEL_NAME", "sample-model") +model_version = os.environ.get("MODEL_VERSION", "1") + +# Construct the path to the local folder of weight files used in this sample. +data_folder = os.environ.get("DATA_FOLDER") +if not data_folder: + data_folder = tempfile.mkdtemp(prefix="sample-model-") + (pathlib.Path(data_folder) / "weights.bin").write_bytes(b"hello-foundry-model") + (pathlib.Path(data_folder) / "config.json").write_text('{"sample": true}') + +with ( + DefaultAzureCredential() as credential, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, +): + + print( + f"Register a local model named `{model_name}` version `{model_version}` " + f"by uploading the contents of `{data_folder}` via `create`." + ) + model = project_client.beta.models.create( + name=model_name, + version=model_version, + source=data_folder, + weight_type=FoundryModelWeightType.FULL_WEIGHT, + description="Sample model registered from sample_models_basic.py", + tags={"source": "sample_models_basic.py"}, + ) + if model is None: + raise RuntimeError( + f"`create` returned None for `{model_name}`@`{model_version}` " + "(use `wait_for_commit=True` to receive the committed ModelVersion)." + ) + print(f"Created (name: {model.name}, version: {model.version}, blob_uri: {model.blob_uri})") + + print(f"Get a specific model version `{model_name}`@`{model_version}`:") + fetched = project_client.beta.models.get(name=model_name, version=model_version) + print(f"Fetched (name: {fetched.name}, version: {fetched.version}, blob_uri: {fetched.blob_uri})") + + print(f"List all versions of model `{model_name}`:") + for mv in project_client.beta.models.list_versions(name=model_name): + print(f" - {mv.version}") + + print("List the latest version of every registered model in this project:") + for mv in project_client.beta.models.list(): + print(f" - {mv.name}@{mv.version}") + + print(f"Get blob credentials for `{model_name}`@`{model_version}`:") + creds = project_client.beta.models.get_credentials( + name=model_name, + version=model_version, + credential_request=ModelCredentialRequest(blob_uri=model.blob_uri), + ) + print(f"Credentials (type: {type(creds).__name__})") + + print(f"Update description and tags on `{model_name}`@`{model_version}`:") + updated = project_client.beta.models.update( + name=model_name, + version=model_version, + body=UpdateModelVersionRequest( + description="Updated description", + tags={"source": "sample_models_basic.py", "updated": "true"}, + ), + ) + print(f"Updated (name: {updated.name}, version: {updated.version}, description: {updated.description})") + + print(f"Delete the model version created above (`{model_name}`@`{model_version}`):") + project_client.beta.models.delete(name=model_name, version=model_version) diff --git a/sdk/ai/azure-ai-projects/samples/models/sample_models_basic_async.py b/sdk/ai/azure-ai-projects/samples/models/sample_models_basic_async.py new file mode 100644 index 000000000000..d0fd70bde4a5 --- /dev/null +++ b/sdk/ai/azure-ai-projects/samples/models/sample_models_basic_async.py @@ -0,0 +1,177 @@ +# pylint: disable=line-too-long,useless-suppression +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ + +""" +DESCRIPTION: + Given an asynchronous AIProjectClient, this sample demonstrates how to + register a local model with a Microsoft Foundry project and exercise the + asynchronous `.beta.models` operations: `pending_upload`, `pending_create_version`, + `get`, `list_versions`, `list`, `get_credentials`, `update`, and `delete`. + + The async client does not expose the `create` convenience helper + (which shells out to the synchronous `azcopy` CLI). This sample instead + drives the spec's three-step upload-first sequence directly: + + 1) `pending_upload(...)` -- the service provisions a project-managed + blob container and returns a SAS URI. + 2) Upload the local weight files to that SAS container using + `azure.storage.blob.aio.ContainerClient`. + 3) `pending_create_version(...)` -- commit the registration. The service returns + 202 Accepted and finalizes the ModelVersion asynchronously, so we + poll `get(...)` until the new version is observable. + +USAGE: + python sample_models_basic_async.py + + Before running the sample: + + pip install "azure-ai-projects>=2.2.0" azure-identity azure-storage-blob aiohttp python-dotenv + + Set these environment variables with your own values: + 1) FOUNDRY_PROJECT_ENDPOINT - Required. The Azure AI Project endpoint, as + found in the overview page of your Microsoft Foundry project. + 2) MODEL_NAME - Optional. The name of the model to register. Defaults to + "sample-model-async". + 3) MODEL_VERSION - Optional. The version of the model to register. + Defaults to "1". + 4) DATA_FOLDER - Optional. The folder containing the local weight files + to upload. Defaults to a temp folder created with two tiny dummy files. +""" + +import asyncio +import os +import pathlib +import tempfile +import time + +from dotenv import load_dotenv + +from azure.core.exceptions import ResourceNotFoundError +from azure.identity.aio import DefaultAzureCredential +from azure.storage.blob.aio import ContainerClient + +from azure.ai.projects.aio import AIProjectClient +from azure.ai.projects.models import ( + FoundryModelWeightType, + ModelCredentialRequest, + ModelPendingUploadRequest, + ModelVersion, + PendingUploadType, + UpdateModelVersionRequest, +) + +load_dotenv() + + +async def main() -> None: + + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + model_name = os.environ.get("MODEL_NAME", "sample-model-async") + model_version = os.environ.get("MODEL_VERSION", "1") + + # Construct the path to the local folder of weight files used in this sample. + data_folder = os.environ.get("DATA_FOLDER") + if not data_folder: + data_folder = tempfile.mkdtemp(prefix="sample-model-async-") + (pathlib.Path(data_folder) / "weights.bin").write_bytes(b"hello-foundry-model") + (pathlib.Path(data_folder) / "config.json").write_text('{"sample": true}') + source_dir = pathlib.Path(data_folder) + + async with ( + DefaultAzureCredential() as credential, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, + ): + + print(f"Step 1/3: pending_upload(name=`{model_name}`, version=`{model_version}`)") + pending = await project_client.beta.models.pending_upload( + name=model_name, + version=model_version, + pending_upload_request=ModelPendingUploadRequest( + pending_upload_type=PendingUploadType.TEMPORARY_BLOB_REFERENCE, + ), + ) + # The wire payload uses the datastore-style `blobReferenceForConsumption` + # shape on some Foundry deployments and the SDK-modeled `blobReference` + # shape on others. Tolerate both. + payload = pending.as_dict() + blob_ref = payload.get("blobReferenceForConsumption") or payload.get("blobReference") or {} + sas_uri = (blob_ref.get("credential") or {}).get("sasUri") + container_blob_uri = blob_ref.get("blobUri") + if not sas_uri or not container_blob_uri: + raise RuntimeError(f"pending_upload response missing SAS / blob URI: {payload!r}") + print(f" blob_uri = {container_blob_uri}") + print(f" sas_uri = {sas_uri.split('?', 1)[0]}?") + + print(f"Step 2/3: upload contents of `{source_dir}` to the SAS container") + async with ContainerClient.from_container_url(sas_uri) as container_client: + for f in [p for p in source_dir.rglob("*") if p.is_file()]: + rel = f.relative_to(source_dir).as_posix() + with f.open("rb") as fp: + await container_client.upload_blob(name=rel, data=fp, overwrite=True) + print(f" uploaded {rel} ({f.stat().st_size} bytes)") + + print(f"Step 3/3: pending_create_version(name=`{model_name}`, version=`{model_version}`)") + await project_client.beta.models.pending_create_version( + name=model_name, + version=model_version, + model_version=ModelVersion( + blob_uri=container_blob_uri, + weight_type=FoundryModelWeightType.FULL_WEIGHT, + description="Sample model registered from sample_models_basic_async.py", + tags={"source": "sample_models_basic_async.py"}, + ), + ) + + # `pending_create_version` returns 202 Accepted; poll `get` until the committed + # ModelVersion is observable. + print(f"Polling get(`{model_name}`, `{model_version}`) until the ModelVersion is committed...") + deadline = time.monotonic() + 300.0 + model: ModelVersion + while True: + try: + model = await project_client.beta.models.get(name=model_name, version=model_version) + break + except ResourceNotFoundError: + if time.monotonic() >= deadline: + raise RuntimeError( + f"Model `{model_name}`@`{model_version}` did not appear within 300s after pending_create_version." + ) + await asyncio.sleep(2.0) + print(f"Created (name: {model.name}, version: {model.version}, blob_uri: {model.blob_uri})") + + print(f"List all versions of model `{model_name}`:") + async for mv in project_client.beta.models.list_versions(name=model_name): + print(f" - {mv.version}") + + print("List the latest version of every registered model in this project:") + async for mv in project_client.beta.models.list(): + print(f" - {mv.name}@{mv.version}") + + print(f"Get blob credentials for `{model_name}`@`{model_version}`:") + creds = await project_client.beta.models.get_credentials( + name=model_name, + version=model_version, + credential_request=ModelCredentialRequest(blob_uri=model.blob_uri), + ) + print(f"Credentials (type: {type(creds).__name__})") + + print(f"Update description and tags on `{model_name}`@`{model_version}`:") + updated = await project_client.beta.models.update( + name=model_name, + version=model_version, + body=UpdateModelVersionRequest( + description="Updated description", + tags={"source": "sample_models_basic_async.py", "updated": "true"}, + ), + ) + print(f"Updated (name: {updated.name}, version: {updated.version}, description: {updated.description})") + + print(f"Delete the model version created above (`{model_name}`@`{model_version}`):") + await project_client.beta.models.delete(name=model_name, version=model_version) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/ai/azure-ai-projects/samples/models/sample_models_without_patch.py b/sdk/ai/azure-ai-projects/samples/models/sample_models_without_patch.py new file mode 100644 index 000000000000..e04c0473c7d9 --- /dev/null +++ b/sdk/ai/azure-ai-projects/samples/models/sample_models_without_patch.py @@ -0,0 +1,141 @@ +# pylint: disable=line-too-long,useless-suppression +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ + +""" +DESCRIPTION: + Given an AIProjectClient, this sample demonstrates how to register a local + model with a Microsoft Foundry project WITHOUT relying on the + `create` helper or the `azcopy` CLI. It hand-rolls the spec's + three-step upload-first sequence using only the generated `.beta.models` + operations and `azure-storage-blob`: + + 1) `pending_upload(...)` -- the service provisions a project-managed + blob container and returns a SAS URI. + 2) Upload the local weight files directly to that SAS container using + `azure.storage.blob.ContainerClient`. + 3) `pending_create_version(...)` -- commit the registration. The service returns + 202 Accepted and finalizes the ModelVersion asynchronously, so we + poll `get(...)` until the new version is observable. + + This is useful when `azcopy` is not available, or when callers want to + integrate the upload step with their own progress reporting / retry logic. + +USAGE: + python sample_models_pending_upload.py + + Before running the sample: + + pip install "azure-ai-projects>=2.2.0" azure-identity azure-storage-blob python-dotenv + + Set these environment variables with your own values: + 1) FOUNDRY_PROJECT_ENDPOINT - Required. The Azure AI Project endpoint, as + found in the overview page of your Microsoft Foundry project. + 2) MODEL_NAME - Optional. The name of the model to register. Defaults to + "sample-model-pending-upload". + 3) MODEL_VERSION - Optional. The version of the model to register. + Defaults to "1". + 4) DATA_FOLDER - Optional. The folder containing the local weight files + to upload. Defaults to a temp folder created with two tiny dummy files. +""" + +import os +import pathlib +import tempfile +import time + +from dotenv import load_dotenv + +from azure.identity import DefaultAzureCredential +from azure.core.exceptions import ResourceNotFoundError +from azure.storage.blob import ContainerClient + +from azure.ai.projects import AIProjectClient +from azure.ai.projects.models import ( + FoundryModelWeightType, + ModelPendingUploadRequest, + ModelVersion, + PendingUploadType, +) + +load_dotenv() + +endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] +model_name = os.environ.get("MODEL_NAME", "sample-model-pending-upload") +model_version = os.environ.get("MODEL_VERSION", "1") + +# Construct the path to the local folder of weight files used in this sample. +data_folder = os.environ.get("DATA_FOLDER") +if not data_folder: + data_folder = tempfile.mkdtemp(prefix="sample-model-") + (pathlib.Path(data_folder) / "weights.bin").write_bytes(b"hello-foundry-model") + (pathlib.Path(data_folder) / "config.json").write_text('{"sample": true}') +source_dir = pathlib.Path(data_folder) + +with ( + DefaultAzureCredential() as credential, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, +): + + print(f"Step 1/3: pending_upload(name=`{model_name}`, version=`{model_version}`)") + pending = project_client.beta.models.pending_upload( + name=model_name, + version=model_version, + pending_upload_request=ModelPendingUploadRequest( + pending_upload_type=PendingUploadType.TEMPORARY_BLOB_REFERENCE, + ), + ) + # The wire payload uses the datastore-style `blobReferenceForConsumption` + # shape on some Foundry deployments and the SDK-modeled `blobReference` + # shape on others. Tolerate both. + payload = pending.as_dict() + blob_ref = payload.get("blobReferenceForConsumption") or payload.get("blobReference") or {} + sas_uri = (blob_ref.get("credential") or {}).get("sasUri") + container_blob_uri = blob_ref.get("blobUri") + if not sas_uri or not container_blob_uri: + raise RuntimeError(f"pending_upload response missing SAS / blob URI: {payload!r}") + print(f" blob_uri = {container_blob_uri}") + print(f" sas_uri = {sas_uri.split('?', 1)[0]}?") + + print(f"Step 2/3: upload contents of `{source_dir}` to the SAS container") + container_client = ContainerClient.from_container_url(sas_uri) + files = [p for p in source_dir.rglob("*") if p.is_file()] + for f in files: + rel = f.relative_to(source_dir).as_posix() + with f.open("rb") as fp: + container_client.upload_blob(name=rel, data=fp, overwrite=True) + print(f" uploaded {rel} ({f.stat().st_size} bytes)") + + print(f"Step 3/3: pending_create_version(name=`{model_name}`, version=`{model_version}`)") + project_client.beta.models.pending_create_version( + name=model_name, + version=model_version, + model_version=ModelVersion( + blob_uri=container_blob_uri, + weight_type=FoundryModelWeightType.FULL_WEIGHT, + description="Sample model registered from sample_models_pending_upload.py", + tags={"source": "sample_models_pending_upload.py"}, + ), + ) + + # `pending_create_version` returns 202 Accepted; poll `get` until the committed + # ModelVersion is observable. + print(f"Polling get(`{model_name}`, `{model_version}`) until the ModelVersion is committed...") + deadline = time.monotonic() + 300.0 + model = None + while True: + try: + model = project_client.beta.models.get(name=model_name, version=model_version) + break + except ResourceNotFoundError: + if time.monotonic() >= deadline: + raise RuntimeError( + f"Model `{model_name}`@`{model_version}` did not appear within 300s after pending_create_version." + ) + time.sleep(2.0) + print(model) + + print(f"Delete the model version created above (`{model_name}`@`{model_version}`):") + project_client.beta.models.delete(name=model_name, version=model_version) diff --git a/sdk/ai/azure-ai-projects/tests/conftest.py b/sdk/ai/azure-ai-projects/tests/conftest.py index 154c6bb3f36a..9ebb3ace9ec6 100644 --- a/sdk/ai/azure-ai-projects/tests/conftest.py +++ b/sdk/ai/azure-ai-projects/tests/conftest.py @@ -134,6 +134,82 @@ def sanitize_url_paths(): # Pattern 2: "Eval Run for -" (agent name already sanitized) add_general_regex_sanitizer(regex=r"sanitized-agent-name -\d{10}", value="sanitized-agent-name -SANITIZED-TS") + # Sanitize per-recording random model name used by `.beta.models` sample tests. + # Live re-recordings need a unique `/` namespace (Foundry's + # asset store reserves it permanently after `delete`), so we use a random + # suffix at recording time and normalize it here so playback URLs match. + add_general_regex_sanitizer(regex=r"recsmplmdl[a-f0-9]+", value="recsmplmdl00000000") + + # Sanitize Foundry project-managed Azure Storage account hostnames returned + # by `.beta.models.pending_upload` (shape: `sa<14 hex chars>.blob.core.windows.net`). + add_general_regex_sanitizer( + regex=r"sa[a-z0-9]{14,}\.blob\.core\.windows\.net", + value="sanitized-storage-account.blob.core.windows.net", + ) + + # Sanitize the per-pending-upload container name returned by Foundry + # (shape: `-pr-`). + add_general_regex_sanitizer( + regex=r"/[a-z0-9-]+-pr-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + value="/sanitized-pending-upload-container", + ) + + # Sanitize SAS-token query strings returned by `.beta.models.pending_upload` + # (signed URLs to a Foundry-managed Storage container). Match conservatively + # on the `sig=` parameter which is unique to SAS tokens and isn't used by + # regular Azure API URLs. + add_general_regex_sanitizer( + regex=r"sig=[A-Za-z0-9%]+", + value="sig=sanitized-sas-sig", + ) + add_general_regex_sanitizer( + regex=r"skoid=[A-Fa-f0-9\-]+", + value="skoid=00000000-0000-0000-0000-000000000000", + ) + add_general_regex_sanitizer( + regex=r"sktid=[A-Fa-f0-9\-]+", + value="sktid=00000000-0000-0000-0000-000000000000", + ) + + # Sanitize `/workspaces/` URL segments (some Foundry asset-store URLs + # reference the underlying ML workspace by name, which leaks the project + # resource name). + add_general_regex_sanitizer( + regex=r"/workspaces/([-\w\._\(\)]+)", + value=sanitized_values["account_name"], + group_for_replace="1", + ) + + # Sanitize Foundry `azureai://` asset URIs whose `accounts/` and + # `projects/` segments embed the project resource name. + add_general_regex_sanitizer( + regex=r"azureai://accounts/([^/]+)/projects/([^/]+)", + value=f"azureai://accounts/{sanitized_values['account_name']}/projects/{sanitized_values['project_name']}", + ) + + # Sanitize the live Foundry project's account/project names anywhere they + # appear in URLs, headers, or bodies. Derived from the live endpoint shape + # `https://.services.ai.azure.com/api/projects/` so we + # cover trailing leaks like `@@AML/...` asset IDs and + # `publisherId` fields that aren't matched by URL-segment sanitizers. + _live_endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT") or os.environ.get("foundry_project_endpoint") + if _live_endpoint: + _ep_match = re.match( + r"https?://(?P[^.]+)\.[^/]+/api/projects/(?P[^/?#]+)", + _live_endpoint, + ) + if _ep_match: + _live_account = _ep_match.group("account") + _live_project = _ep_match.group("project") + # Order matters: the longer (account) name often contains the shorter + # (project) name as a prefix; replace the longer one first. + for _name, _placeholder in ( + (_live_account, sanitized_values["account_name"]), + (_live_project, sanitized_values["project_name"]), + ): + add_general_regex_sanitizer(regex=re.escape(_name), value=_placeholder) + add_body_string_sanitizer(target=_name, value=_placeholder) + # Sanitize image-generation deployment name from live env when present. # This value is commonly emitted in request headers (for example # `x-ms-oai-image-generation-deployment`) and may come from either diff --git a/sdk/ai/azure-ai-projects/tests/foundry_features_header/foundry_features_header_test_base.py b/sdk/ai/azure-ai-projects/tests/foundry_features_header/foundry_features_header_test_base.py index b20ee15b9ce7..2c5c275b9f35 100644 --- a/sdk/ai/azure-ai-projects/tests/foundry_features_header/foundry_features_header_test_base.py +++ b/sdk/ai/azure-ai-projects/tests/foundry_features_header/foundry_features_header_test_base.py @@ -49,6 +49,27 @@ "agents": "HostedAgents=V1Preview,WorkflowAgents=V1Preview,AgentEndpoints=V1Preview,CodeAgents=V1Preview,ExternalAgents=V1Preview,AgentsOptimization=V1Preview", } +# Methods on .beta sub-clients that are NOT simple one-HTTP-call wrappers and +# therefore cannot be exercised by the generic header-injection test (which +# captures the first outgoing HttpRequest). +# +# Multi-step orchestrator helpers (validate locally -> HTTP -> external process +# -> HTTP -> poll) fall into this bucket: they perform local input validation +# and/or external side effects (e.g. subprocess calls) before the first HTTP +# request, so synthetic placeholder arguments cause them to abort with a +# TypeError/ValueError/RuntimeError before any request is ever sent. +# +# The header-injection invariant is still enforced for these methods because +# every nested HTTP call they make is routed through other public sub-client +# methods that ARE covered by this test (e.g. .beta.models.create internally +# calls .beta.models.pending_upload and .beta.models.pending_create_version, +# both of which are tested separately and pass). +# +# Format: { "": frozenset({"", ...}) } +EXCLUDED_BETA_METHODS: dict[str, frozenset] = { + "models": frozenset({"create"}), # multi-step helper: validate -> pending_upload -> azcopy -> pending_create_version -> poll get +} + # Shared test cases for non-beta methods that optionally send the Foundry-Features header. # Used by both test_foundry_features_header_optional.py (sync) and # test_foundry_features_header_optional_async.py (async). diff --git a/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_on_beta_operations.py b/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_on_beta_operations.py index ac3186a1225f..43db5c0811f6 100644 --- a/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_on_beta_operations.py +++ b/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_on_beta_operations.py @@ -38,6 +38,7 @@ from azure.ai.projects import AIProjectClient from foundry_features_header_test_base import ( + EXCLUDED_BETA_METHODS, EXPECTED_FOUNDRY_FEATURES, FAKE_ENDPOINT, FOUNDRY_FEATURES_HEADER, @@ -111,9 +112,12 @@ def _discover_test_cases() -> list[pytest.param]: # return no public methods. Methods are still fetched via getattr(sc, ...) so # the header-injecting proxy wrapper is exercised. _underlying_op = getattr(sc, "_operation", sc) + _excluded = EXCLUDED_BETA_METHODS.get(sc_name, frozenset()) for m_name in sorted(dir(_underlying_op)): if m_name.startswith("_"): continue + if m_name in _excluded: + continue method = getattr(sc, m_name) if not callable(method): continue diff --git a/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_on_beta_operations_async.py b/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_on_beta_operations_async.py index 30eed85005a5..afb065d6a155 100644 --- a/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_on_beta_operations_async.py +++ b/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_on_beta_operations_async.py @@ -40,6 +40,7 @@ from azure.ai.projects.aio import AIProjectClient as AsyncAIProjectClient from foundry_features_header_test_base import ( + EXCLUDED_BETA_METHODS, EXPECTED_FOUNDRY_FEATURES, FAKE_ENDPOINT, FOUNDRY_FEATURES_HEADER, @@ -116,9 +117,12 @@ def _discover_async_test_cases() -> list[pytest.param]: # return no public methods. Methods are still fetched via getattr(sc, ...) so # the header-injecting proxy wrapper is exercised. _underlying_op = getattr(sc, "_operation", sc) + _excluded = EXCLUDED_BETA_METHODS.get(sc_name, frozenset()) for m_name in sorted(dir(_underlying_op)): if m_name.startswith("_"): continue + if m_name in _excluded: + continue method = getattr(sc, m_name) if not callable(method): continue diff --git a/sdk/ai/azure-ai-projects/tests/models/test_models.py b/sdk/ai/azure-ai-projects/tests/models/test_models.py new file mode 100644 index 000000000000..356a83d14d3e --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/models/test_models.py @@ -0,0 +1,141 @@ +# pylint: disable=line-too-long,useless-suppression +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +"""Live, recorded tests for ``project_client.beta.models``. + +These tests exercise the generated ``BetaModelsOperations`` (``list``, +``list_versions``, ``get``, ``pending_upload``, ``get_credentials``, ``delete``) +and the patched ``create`` end-to-end helper. They follow the same +"upload + record" pattern used by ``test_datasets.py``. + +``create_or_update`` is intentionally not tested here. The Foundry data plane +currently returns ``404 Not Found`` for that route even when ``GET`` for the +same name/version succeeds. The cell will be re-enabled once the service-side +issue is fixed. +""" + +import os + +import pytest +from devtools_testutils import ( + add_general_regex_sanitizer, + is_live, + is_live_and_not_recording, + recorded_by_proxy, +) +from azure.core.exceptions import HttpResponseError, ResourceNotFoundError + +from test_base import TestBase, servicePreparer + +# Construct the path to the data folder used in this test +script_dir = os.path.dirname(os.path.abspath(__file__)) +data_folder = os.environ.get("DATA_FOLDER", os.path.join(script_dir, "../test_data/models")) + + +@pytest.mark.skipif( + not is_live_and_not_recording(), + reason="Skipped when using recordings due to flakiness of recording blob storage calls", +) +class TestModels(TestBase): + + # cls & pytest tests\models\test_models.py::TestModels::test_create -s + @servicePreparer() + @recorded_by_proxy + def test_create(self, **kwargs): + """End-to-end: pending_upload -> azcopy -> pending_create_version -> get/list/delete.""" + model_name = self.test_models_params["model_name_1"] + model_version = self.test_models_params["model_version"] + expected_model_name = model_name if is_live() else "sanitized-model-name" + add_general_regex_sanitizer(regex=r"test-model-name-\d{5}", value="sanitized-model-name", function_scoped=True) + + with self.create_client(**kwargs) as project_client: + + print(f"[test_create] create {model_name}@{model_version}") + registered = project_client.beta.models.create( + name=model_name, + version=model_version, + source=data_folder, + weight_type="FullWeight", + description="Registered by test_create", + tags={"source": "test_models.py"}, + ) + assert registered is not None + assert registered.name == expected_model_name + assert registered.version == model_version + assert registered.blob_uri, "blob_uri should be populated after create" + + print(f"[test_create] get {model_name}@{model_version}") + fetched = project_client.beta.models.get(name=model_name, version=model_version) + assert fetched.id == registered.id + assert fetched.name == expected_model_name + assert fetched.version == model_version + + print(f"[test_create] list_versions({model_name!r})") + versions = list(project_client.beta.models.list_versions(name=model_name)) + assert any( + mv.version == model_version for mv in versions + ), f"version {model_version!r} not found in list_versions" + + print("[test_create] list (latest of every model)") + empty = True + for mv in project_client.beta.models.list(): + empty = False + assert mv.name and mv.version + assert not empty, "list() returned no models even though we just registered one" + + print(f"[test_create] get_credentials {model_name}@{model_version}") + from azure.ai.projects.models import ModelCredentialRequest + + creds = project_client.beta.models.get_credentials( + name=model_name, + version=model_version, + body=ModelCredentialRequest(blob_uri=registered.blob_uri), + ) + blob_ref = getattr(creds, "blob_reference_for_consumption", None) or getattr(creds, "blob_reference", None) + assert blob_ref is not None, f"no blob reference in credentials response: {creds!r}" + assert blob_ref.blob_uri + assert blob_ref.credential is not None + assert blob_ref.credential.sas_uri + + print(f"[test_create] delete {model_name}@{model_version}") + try: + project_client.beta.models.delete(name=model_name, version=model_version) + except HttpResponseError as ex: + # The service currently returns 200 OK for a successful DELETE while + # the generated operation only allow-lists 204. Tolerate that here. + if ex.status_code != 200: + raise + + print(f"[test_create] get on deleted {model_name}@{model_version} should 404") + with pytest.raises((ResourceNotFoundError, HttpResponseError)): + project_client.beta.models.get(name=model_name, version=model_version) + + # cls & pytest tests\models\test_models.py::TestModels::test_models_pending_upload -s + @servicePreparer() + @recorded_by_proxy + def test_models_pending_upload(self, **kwargs): + """Lower-level: ``pending_upload`` returns a usable SAS URI.""" + from azure.ai.projects.models import ModelPendingUploadRequest, PendingUploadType + + model_name = self.test_models_params["model_name_2"] + model_version = self.test_models_params["model_version"] + add_general_regex_sanitizer(regex=r"test-model-name-\d{5}", value="sanitized-model-name", function_scoped=True) + + with self.create_client(**kwargs) as project_client: + + print(f"[test_models_pending_upload] pending_upload {model_name}@{model_version}") + pending = project_client.beta.models.pending_upload( + name=model_name, + version=model_version, + body=ModelPendingUploadRequest( + pending_upload_type=PendingUploadType.TEMPORARY_BLOB_REFERENCE, + ), + ) + payload = pending.as_dict() if hasattr(pending, "as_dict") else dict(pending) + blob_ref = payload.get("blobReferenceForConsumption") or payload.get("blobReference") or {} + assert (blob_ref.get("credential") or {}).get( + "sasUri" + ), f"pending_upload response missing SAS URI: {payload!r}" + assert blob_ref.get("blobUri"), f"pending_upload response missing blobUri: {payload!r}" diff --git a/sdk/ai/azure-ai-projects/tests/models/test_models_async.py b/sdk/ai/azure-ai-projects/tests/models/test_models_async.py new file mode 100644 index 000000000000..b92c0c6e54c1 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/models/test_models_async.py @@ -0,0 +1,98 @@ +# pylint: disable=line-too-long,useless-suppression +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +"""Live, recorded async tests for ``project_client.beta.models``. + +Mirrors :mod:`tests.models.test_models` for the async client. ``create`` +itself is implemented only on the sync client (it shells out to ``azcopy``); the +async surface is exercised via ``list``, ``list_versions``, ``get`` and +``delete`` against a model registered with the sync helper as part of the +fixture. +""" + +import os + +import pytest +from devtools_testutils import ( + add_general_regex_sanitizer, + is_live, + is_live_and_not_recording, +) +from devtools_testutils.aio import recorded_by_proxy_async +from azure.core.exceptions import HttpResponseError, ResourceNotFoundError + +from test_base import TestBase, servicePreparer + +script_dir = os.path.dirname(os.path.abspath(__file__)) +data_folder = os.environ.get("DATA_FOLDER", os.path.join(script_dir, "../test_data/models")) + + +@pytest.mark.skipif( + not is_live_and_not_recording(), + reason="Skipped when using recordings due to flakiness of recording blob storage calls", +) +class TestModelsAsync(TestBase): + + # cls & pytest tests\models\test_models_async.py::TestModelsAsync::test_models_async_list_get_delete -s + @servicePreparer() + @recorded_by_proxy_async + async def test_models_async_list_get_delete(self, **kwargs): + """Register a model with the sync helper, then drive list/get/delete async.""" + from azure.ai.projects import AIProjectClient as SyncAIProjectClient + + model_name = self.test_models_params["model_name_1"] + model_version = self.test_models_params["model_version"] + expected_model_name = model_name if is_live() else "sanitized-model-name" + add_general_regex_sanitizer(regex=r"test-model-name-\d{5}", value="sanitized-model-name", function_scoped=True) + + endpoint = kwargs["foundry_project_endpoint"] + + # Set up: register a model using the sync helper (azcopy is sync). + with SyncAIProjectClient( + endpoint=endpoint, + credential=self.get_credential(SyncAIProjectClient, is_async=False), + ) as sync_client: + registered = sync_client.beta.models.create( + name=model_name, + version=model_version, + source=data_folder, + weight_type="FullWeight", + description="Registered by test_models_async", + tags={"source": "test_models_async.py"}, + ) + assert registered is not None + assert registered.name == expected_model_name + + # Exercise: drive the async client. + async with self.create_async_client(**kwargs) as project_client: + + print(f"[test_models_async] get {model_name}@{model_version}") + fetched = await project_client.beta.models.get(name=model_name, version=model_version) + assert fetched.name == expected_model_name + assert fetched.version == model_version + + print(f"[test_models_async] list_versions({model_name!r})") + versions = [] + async for mv in project_client.beta.models.list_versions(name=model_name): + versions.append(mv) + assert any(mv.version == model_version for mv in versions) + + print("[test_models_async] list (latest of every model)") + seen = 0 + async for mv in project_client.beta.models.list(): + seen += 1 + assert mv.name and mv.version + assert seen > 0 + + print(f"[test_models_async] delete {model_name}@{model_version}") + try: + await project_client.beta.models.delete(name=model_name, version=model_version) + except HttpResponseError as ex: + if ex.status_code != 200: + raise + + print(f"[test_models_async] get on deleted {model_name}@{model_version} should 404") + with pytest.raises((ResourceNotFoundError, HttpResponseError)): + await project_client.beta.models.get(name=model_name, version=model_version) diff --git a/sdk/ai/azure-ai-projects/tests/models/test_patch_models.py b/sdk/ai/azure-ai-projects/tests/models/test_patch_models.py new file mode 100644 index 000000000000..d2c8dd6da57a --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/models/test_patch_models.py @@ -0,0 +1,440 @@ +# pylint: disable=line-too-long,useless-suppression +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +"""Offline unit tests for ``azure.ai.projects.operations._patch_models``. + +These tests do not contact the Foundry service. They cover the patch helpers +``_extract_pending_upload_targets`` and ``_run_azcopy``, and the orchestration +performed by ``create`` (mocking ``pending_upload``, ``pending_create_version`` +and ``get`` on the base class). +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +import pytest + +from azure.core.exceptions import ResourceNotFoundError + +from azure.ai.projects.operations._patch_models import BetaModelsOperations + +# --------------------------------------------------------------------------- +# _extract_pending_upload_targets +# --------------------------------------------------------------------------- + + +class TestExtractPendingUploadTargets: + def test_datastore_wire_shape(self): + """The service currently returns ``blobReferenceForConsumption``.""" + payload = { + "blobReferenceForConsumption": { + "blobUri": "https://acct.blob.core.windows.net/c/path", + "credential": {"sasUri": "https://acct.blob.core.windows.net/c/path?sig=abc"}, + }, + "temporaryDataReferenceId": "abc-123", + } + sas, blob, pid = BetaModelsOperations._extract_pending_upload_targets(payload) + assert sas == payload["blobReferenceForConsumption"]["credential"]["sasUri"] + assert blob == payload["blobReferenceForConsumption"]["blobUri"] + assert pid == "abc-123" + + def test_modeled_wire_shape(self): + """The SDK-modeled shape uses ``blobReference`` / ``pendingUploadId``.""" + payload = { + "blobReference": { + "blobUri": "https://acct.blob.core.windows.net/c/path", + "credential": {"sasUri": "https://acct.blob.core.windows.net/c/path?sig=xyz"}, + }, + "pendingUploadId": "modeled-id", + } + sas, blob, pid = BetaModelsOperations._extract_pending_upload_targets(payload) + assert sas == payload["blobReference"]["credential"]["sasUri"] + assert blob == payload["blobReference"]["blobUri"] + assert pid == "modeled-id" + + def test_response_object_with_as_dict(self): + """Accept anything exposing ``.as_dict()`` (the modeled response type).""" + payload = { + "blobReference": { + "blobUri": "https://x/y", + "credential": {"sasUri": "https://x/y?sas"}, + }, + "pendingUploadId": "id1", + } + response = SimpleNamespace(as_dict=lambda: payload) + sas, blob, pid = BetaModelsOperations._extract_pending_upload_targets(response) + assert (sas, blob, pid) == ("https://x/y?sas", "https://x/y", "id1") + + def test_missing_sas_uri_raises(self): + payload = { + "blobReferenceForConsumption": {"blobUri": "https://x/y", "credential": {}}, + } + with pytest.raises(ValueError, match="SAS URI / blob URI"): + BetaModelsOperations._extract_pending_upload_targets(payload) + + def test_missing_blob_uri_raises(self): + payload = { + "blobReferenceForConsumption": {"credential": {"sasUri": "https://x?sas"}}, + } + with pytest.raises(ValueError, match="SAS URI / blob URI"): + BetaModelsOperations._extract_pending_upload_targets(payload) + + def test_missing_pending_upload_id_is_none(self): + payload = { + "blobReferenceForConsumption": { + "blobUri": "https://x/y", + "credential": {"sasUri": "https://x/y?sas"}, + }, + } + sas, blob, pid = BetaModelsOperations._extract_pending_upload_targets(payload) + assert pid is None + assert (sas, blob) == ("https://x/y?sas", "https://x/y") + + +# --------------------------------------------------------------------------- +# _run_azcopy +# --------------------------------------------------------------------------- + + +class TestRunAzcopy: + def test_missing_azcopy_raises_runtime_error(self, tmp_path): + src = tmp_path / "weights.bin" + src.write_bytes(b"x") + with mock.patch("azure.ai.projects.operations._patch_models.shutil.which", return_value=None): + with pytest.raises(RuntimeError, match="azcopy"): + BetaModelsOperations._run_azcopy(src, "https://x?sas") + + def test_explicit_path_overrides_shutil_which(self, tmp_path): + src = tmp_path / "weights.bin" + src.write_bytes(b"x") + completed = SimpleNamespace(returncode=0, stdout="", stderr="") + with mock.patch( + "azure.ai.projects.operations._patch_models.subprocess.run", + return_value=completed, + ) as run, mock.patch("azure.ai.projects.operations._patch_models.shutil.which", return_value=None): + BetaModelsOperations._run_azcopy(src, "https://x?sas", azcopy_path="/opt/azcopy") + assert run.call_args.args[0][0] == "/opt/azcopy" + + def test_directory_source_uses_glob_arg(self, tmp_path): + (tmp_path / "weights.bin").write_bytes(b"x") + completed = SimpleNamespace(returncode=0, stdout="", stderr="") + with mock.patch( + "azure.ai.projects.operations._patch_models.subprocess.run", + return_value=completed, + ) as run, mock.patch("azure.ai.projects.operations._patch_models.shutil.which", return_value="/usr/bin/azcopy"): + BetaModelsOperations._run_azcopy(tmp_path, "https://x?sas") + cmd = run.call_args.args[0] + assert cmd[0] == "/usr/bin/azcopy" + assert cmd[1] == "copy" + assert cmd[2] == str(tmp_path / "*") + assert cmd[3] == "https://x?sas" + assert "--from-to" in cmd + assert cmd[cmd.index("--from-to") + 1] == "LocalBlob" + assert "--recursive" in cmd + + def test_file_source_uses_file_arg(self, tmp_path): + src = tmp_path / "weights.bin" + src.write_bytes(b"x") + completed = SimpleNamespace(returncode=0, stdout="", stderr="") + with mock.patch( + "azure.ai.projects.operations._patch_models.subprocess.run", + return_value=completed, + ) as run, mock.patch("azure.ai.projects.operations._patch_models.shutil.which", return_value="/usr/bin/azcopy"): + BetaModelsOperations._run_azcopy(src, "https://x?sas") + assert run.call_args.args[0][2] == str(src) + + def test_missing_source_raises_value_error(self, tmp_path): + ghost = tmp_path / "does-not-exist" + with mock.patch("azure.ai.projects.operations._patch_models.shutil.which", return_value="/usr/bin/azcopy"): + with pytest.raises(ValueError, match="does not exist"): + BetaModelsOperations._run_azcopy(ghost, "https://x?sas") + + def test_nonzero_exit_raises_runtime_error(self, tmp_path): + src = tmp_path / "weights.bin" + src.write_bytes(b"x") + completed = SimpleNamespace(returncode=1, stdout="oops-stdout", stderr="oops-stderr") + with mock.patch( + "azure.ai.projects.operations._patch_models.subprocess.run", + return_value=completed, + ), mock.patch("azure.ai.projects.operations._patch_models.shutil.which", return_value="/usr/bin/azcopy"): + with pytest.raises(RuntimeError, match="exited with code 1"): + BetaModelsOperations._run_azcopy(src, "https://x?sas") + + +# --------------------------------------------------------------------------- +# create orchestration +# --------------------------------------------------------------------------- + + +def _make_ops() -> BetaModelsOperations: + """Build a ``BetaModelsOperations`` without going through the client wiring.""" + return BetaModelsOperations.__new__(BetaModelsOperations) + + +def _pending_payload() -> dict: + return { + "blobReferenceForConsumption": { + "blobUri": "https://acct.blob.core.windows.net/c/path", + "credential": {"sasUri": "https://acct.blob.core.windows.net/c/path?sig=abc"}, + }, + "temporaryDataReferenceId": "pending-id-1", + } + + +class TestCreateVersionOrchestration: + @pytest.fixture(autouse=True) + def _stub_azcopy_on_path(self): + """Pretend azcopy is installed so the up-front validator passes.""" + with mock.patch( + "azure.ai.projects.operations._patch_models.shutil.which", + return_value="/usr/bin/azcopy", + ): + yield + + def test_models_create_runs_three_steps_in_order_and_returns_get_result(self, tmp_path): + (tmp_path / "weights.bin").write_bytes(b"x") + ops = _make_ops() + + committed = SimpleNamespace( + id="model/abc", + name="my-model", + version="1", + blob_uri="https://acct.blob.core.windows.net/c/path", + ) + calls: list[str] = [] + + def fake_pending_upload(**kwargs): + calls.append("pending_upload") + assert kwargs["name"] == "my-model" + assert kwargs["version"] == "1" + return _pending_payload() + + def fake_run_azcopy(source, sas_uri, *, azcopy_path=None): # noqa: ARG001 + calls.append("azcopy") + assert Path(source) == tmp_path + assert sas_uri.startswith("https://") + assert azcopy_path == "/custom/azcopy" + + def fake_create_async(**kwargs): + calls.append("pending_create_version") + assert kwargs["name"] == "my-model" + assert kwargs["version"] == "1" + body = kwargs["model_version"] + # The blob_uri from the pending response is plumbed into the commit body. + assert body.blob_uri == "https://acct.blob.core.windows.net/c/path" + assert body.weight_type == "FullWeight" + assert body.description == "desc" + assert body.tags == {"k": "v"} + + def fake_get(**kwargs): + calls.append("get") + assert kwargs["name"] == "my-model" + assert kwargs["version"] == "1" + return committed + + with mock.patch.object(ops, "pending_upload", side_effect=fake_pending_upload), mock.patch.object( + BetaModelsOperations, "_run_azcopy", staticmethod(fake_run_azcopy) + ), mock.patch.object(ops, "pending_create_version", side_effect=fake_create_async), mock.patch.object( + ops, "get", side_effect=fake_get + ): + result = ops.create( + name="my-model", + version="1", + source=tmp_path, + weight_type="FullWeight", + description="desc", + tags={"k": "v"}, + azcopy_path="/custom/azcopy", + ) + + assert result is committed + assert calls == ["pending_upload", "azcopy", "pending_create_version", "get"] + + def test_models_create_wait_for_commit_false_returns_none_and_does_not_poll(self, tmp_path): + (tmp_path / "weights.bin").write_bytes(b"x") + ops = _make_ops() + get_mock = mock.Mock() + + with mock.patch.object(ops, "pending_upload", return_value=_pending_payload()), mock.patch.object( + BetaModelsOperations, "_run_azcopy", staticmethod(lambda *a, **kw: None) + ), mock.patch.object(ops, "pending_create_version", return_value=None), mock.patch.object(ops, "get", get_mock): + result = ops.create( + name="m", + version="1", + source=tmp_path, + wait_for_commit=False, + ) + + assert result is None + get_mock.assert_not_called() + + def test_models_create_polls_until_get_succeeds(self, tmp_path): + (tmp_path / "weights.bin").write_bytes(b"x") + ops = _make_ops() + committed = SimpleNamespace(name="m", version="1") + + get_mock = mock.Mock( + side_effect=[ + ResourceNotFoundError(message="not yet"), + ResourceNotFoundError(message="still not yet"), + committed, + ] + ) + + with mock.patch.object(ops, "pending_upload", return_value=_pending_payload()), mock.patch.object( + BetaModelsOperations, "_run_azcopy", staticmethod(lambda *a, **kw: None) + ), mock.patch.object(ops, "pending_create_version", return_value=None), mock.patch.object( + ops, "get", get_mock + ), mock.patch( + "azure.ai.projects.operations._patch_models.time.sleep" + ) as sleep: + result = ops.create( + name="m", + version="1", + source=tmp_path, + polling_interval=0.01, + ) + + assert result is committed + assert get_mock.call_count == 3 + assert sleep.call_count == 2 + + def test_models_create_polling_timeout_raises_runtime_error(self, tmp_path): + (tmp_path / "weights.bin").write_bytes(b"x") + ops = _make_ops() + + # First a real time.monotonic call (start), then an over-the-deadline one. + times = iter([1000.0, 1000.0, 9999.0]) + with mock.patch.object(ops, "pending_upload", return_value=_pending_payload()), mock.patch.object( + BetaModelsOperations, "_run_azcopy", staticmethod(lambda *a, **kw: None) + ), mock.patch.object(ops, "pending_create_version", return_value=None), mock.patch.object( + ops, "get", side_effect=ResourceNotFoundError(message="never") + ), mock.patch( + "azure.ai.projects.operations._patch_models.time.monotonic", + side_effect=lambda: next(times), + ), mock.patch( + "azure.ai.projects.operations._patch_models.time.sleep" + ): + with pytest.raises(RuntimeError, match="did not appear within"): + ops.create( + name="m", + version="1", + source=tmp_path, + polling_timeout=1.0, + ) + + def test_models_create_missing_source_raises_before_calling_service(self, tmp_path): + ops = _make_ops() + ghost = tmp_path / "does-not-exist" + pending = mock.Mock() + with mock.patch.object(ops, "pending_upload", pending): + with pytest.raises(ValueError, match="does not exist"): + ops.create(name="m", version="1", source=ghost) + pending.assert_not_called() + + +# --------------------------------------------------------------------------- +# _validate_create_inputs +# --------------------------------------------------------------------------- + + +class TestValidateCreateVersionInputs: + @pytest.fixture(autouse=True) + def _stub_azcopy_on_path(self): + with mock.patch( + "azure.ai.projects.operations._patch_models.shutil.which", + return_value="/usr/bin/azcopy", + ): + yield + + def _kwargs(self, **overrides): + defaults = dict( + name="m", + version="1", + source="/tmp/x", + azcopy_path=None, + wait_for_commit=True, + polling_timeout=300.0, + polling_interval=2.0, + ) + defaults.update(overrides) + return defaults + + def test_valid_directory_source_returns_path(self, tmp_path): + (tmp_path / "weights.bin").write_bytes(b"x") + result = BetaModelsOperations._validate_create_inputs(**self._kwargs(source=tmp_path)) + assert result == tmp_path + + @pytest.mark.parametrize("bad_name", ["", " ", None, 123]) + def test_empty_or_non_string_name_raises(self, tmp_path, bad_name): + (tmp_path / "weights.bin").write_bytes(b"x") + with pytest.raises(ValueError, match="`name`"): + BetaModelsOperations._validate_create_inputs(**self._kwargs(name=bad_name, source=tmp_path)) + + @pytest.mark.parametrize("bad_version", ["", " ", None, 1]) + def test_empty_or_non_string_version_raises(self, tmp_path, bad_version): + (tmp_path / "weights.bin").write_bytes(b"x") + with pytest.raises(ValueError, match="`version`"): + BetaModelsOperations._validate_create_inputs(**self._kwargs(version=bad_version, source=tmp_path)) + + def test_empty_directory_raises(self, tmp_path): + with pytest.raises(ValueError, match="directory is empty"): + BetaModelsOperations._validate_create_inputs(**self._kwargs(source=tmp_path)) + + def test_empty_file_raises(self, tmp_path): + empty = tmp_path / "weights.bin" + empty.write_bytes(b"") + with pytest.raises(ValueError, match="file is empty"): + BetaModelsOperations._validate_create_inputs(**self._kwargs(source=empty)) + + @pytest.mark.parametrize("bad_timeout", [0, -1.0]) + def test_non_positive_polling_timeout_raises(self, tmp_path, bad_timeout): + (tmp_path / "weights.bin").write_bytes(b"x") + with pytest.raises(ValueError, match="polling_timeout"): + BetaModelsOperations._validate_create_inputs( + **self._kwargs(source=tmp_path, polling_timeout=bad_timeout) + ) + + @pytest.mark.parametrize("bad_interval", [0, -1.0]) + def test_non_positive_polling_interval_raises(self, tmp_path, bad_interval): + (tmp_path / "weights.bin").write_bytes(b"x") + with pytest.raises(ValueError, match="polling_interval"): + BetaModelsOperations._validate_create_inputs( + **self._kwargs(source=tmp_path, polling_interval=bad_interval) + ) + + def test_polling_params_skipped_when_wait_for_commit_false(self, tmp_path): + (tmp_path / "weights.bin").write_bytes(b"x") + # Negative polling values are tolerated when not waiting for commit. + result = BetaModelsOperations._validate_create_inputs( + **self._kwargs(source=tmp_path, wait_for_commit=False, polling_timeout=-1, polling_interval=-1) + ) + assert result == tmp_path + + def test_missing_azcopy_raises(self, tmp_path): + (tmp_path / "weights.bin").write_bytes(b"x") + with mock.patch( + "azure.ai.projects.operations._patch_models.shutil.which", + return_value=None, + ): + with pytest.raises(RuntimeError, match="azcopy"): + BetaModelsOperations._validate_create_inputs(**self._kwargs(source=tmp_path)) + + def test_models_create_validates_before_calling_pending_upload(self, tmp_path): + """Validation runs before any service operation.""" + (tmp_path / "weights.bin").write_bytes(b"x") + ops = _make_ops() + pending = mock.Mock() + with mock.patch.object(ops, "pending_upload", pending), mock.patch( + "azure.ai.projects.operations._patch_models.shutil.which", return_value=None + ): + with pytest.raises(RuntimeError, match="azcopy"): + ops.create(name="m", version="1", source=tmp_path) + pending.assert_not_called() diff --git a/sdk/ai/azure-ai-projects/tests/samples/test_samples.py b/sdk/ai/azure-ai-projects/tests/samples/test_samples.py index a2d97b558c8f..8179f5b6b2c9 100644 --- a/sdk/ai/azure-ai-projects/tests/samples/test_samples.py +++ b/sdk/ai/azure-ai-projects/tests/samples/test_samples.py @@ -6,7 +6,7 @@ import pytest import os from devtools_testutils import recorded_by_proxy, AzureRecordedTestCase, RecordedTransport -from test_base import servicePreparer, fineTuningServicePreparer +from test_base import servicePreparer, fineTuningServicePreparer, modelsServicePreparer from sample_executor import ( AdditionalSampleTestDetail, SyncSampleExecutor, @@ -150,6 +150,40 @@ def test_deployments_samples(self, sample_path: str, **kwargs) -> None: executor.execute() executor.validate_print_calls_by_llm() + @pytest.mark.parametrize( + "sample_path", + get_sample_paths( + "models", + samples_to_test=[ + # `sample_models_basic.py` uses the `create()` helper which shells out + # to AzCopy. AzCopy traffic isn't captured by the test proxy, so the + # sample can't be replayed from a recording. Live re-recording is still + # exercised via the standalone tests in `tests/models/`. + "sample_models_without_patch.py", + ], + ), + ) + @modelsServicePreparer() + @SamplePathPasser() + @recorded_by_proxy(RecordedTransport.AZURE_CORE, RecordedTransport.HTTPX) + def test_models_samples(self, sample_path: str, **kwargs) -> None: + import secrets # local import to avoid module-level dep + + env_vars = get_sample_env_vars(kwargs) + # Foundry permanently reserves a `/` asset namespace even + # after `models.delete`, so every live re-recording needs a unique name. + # Sanitize back to a stable value in conftest so playback URLs match. + suffix = secrets.token_hex(4) if self.is_live else "00000000" + env_vars["MODEL_NAME"] = f"recsmplmdl{suffix}" + env_vars["MODEL_VERSION"] = "1" + executor = SyncSampleExecutor(self, sample_path, env_vars=env_vars, **kwargs) + executor.execute() + # `validate_print_calls_by_llm` is intentionally not called: it requires + # an Azure OpenAI connection on the Foundry project, which the canary + # project used for `.beta.models` recordings does not have. The sample + # is still validated end-to-end by `executor.execute()` (any exception + # fails the test). + @servicePreparer() @additionalSampleTests( [ diff --git a/sdk/ai/azure-ai-projects/tests/samples/test_samples_async.py b/sdk/ai/azure-ai-projects/tests/samples/test_samples_async.py index c4b46ef8a6a7..e3b76dacf469 100644 --- a/sdk/ai/azure-ai-projects/tests/samples/test_samples_async.py +++ b/sdk/ai/azure-ai-projects/tests/samples/test_samples_async.py @@ -6,7 +6,7 @@ import pytest, os from devtools_testutils.aio import recorded_by_proxy_async from devtools_testutils import AzureRecordedTestCase, RecordedTransport -from test_base import servicePreparer +from test_base import servicePreparer, modelsServicePreparer from sample_executor import ( AdditionalSampleTestDetail, AsyncSampleExecutor, @@ -138,6 +138,33 @@ async def test_deployments_samples(self, sample_path: str, **kwargs) -> None: await executor.execute_async() await executor.validate_print_calls_by_llm_async() + @pytest.mark.parametrize( + "sample_path", + get_async_sample_paths( + "models", + samples_to_test=[ + "sample_models_basic_async.py", + ], + ), + ) + @modelsServicePreparer() + @SamplePathPasser() + @recorded_by_proxy_async(RecordedTransport.AZURE_CORE, RecordedTransport.HTTPX) + async def test_models_samples(self, sample_path: str, **kwargs) -> None: + import secrets # local import to avoid module-level dep + + env_vars = get_sample_env_vars(kwargs) + # Foundry permanently reserves a `/` asset namespace even + # after `models.delete`, so every live re-recording needs a unique name. + # Sanitize back to a stable value in conftest so playback URLs match. + suffix = secrets.token_hex(4) if self.is_live else "00000000" + env_vars["MODEL_NAME"] = f"recsmplmdl{suffix}" + env_vars["MODEL_VERSION"] = "1" + executor = AsyncSampleExecutor(self, sample_path, env_vars=env_vars, **kwargs) + await executor.execute_async() + # `validate_print_calls_by_llm_async` is intentionally not called: see + # the comment on the synchronous `test_models_samples` for details. + @pytest.mark.parametrize( "sample_path", get_async_sample_paths( diff --git a/sdk/ai/azure-ai-projects/tests/test_base.py b/sdk/ai/azure-ai-projects/tests/test_base.py index 666121f7a53f..5b69b45ce21a 100644 --- a/sdk/ai/azure-ai-projects/tests/test_base.py +++ b/sdk/ai/azure-ai-projects/tests/test_base.py @@ -87,6 +87,16 @@ azure_ai_projects_azure_aoai_account="sanitized-aoai-account", ) +# Slim preparer for `.beta.models` samples/tests. These exercise local-file +# upload + ModelVersion registration; they only need a Foundry project endpoint +# and the LLM-validation endpoint used by sample tests. +modelsServicePreparer = functools.partial( + EnvironmentVariableLoader, + "", + foundry_project_endpoint="https://sanitized-account-name.services.ai.azure.com/api/projects/sanitized-project-name", + llm_validation_project_endpoint="https://sanitized-account-name.services.ai.azure.com/api/projects/sanitized-project-name", +) + # Fine-tuning job type constants SFT_JOB_TYPE: Final[str] = "sft" DPO_JOB_TYPE: Final[str] = "dpo" @@ -206,6 +216,12 @@ class TestBase(AzureRecordedTestCase): "connection_name": "balapvbyostoragecanary", } + test_models_params = { + "model_name_1": f"test-model-name-{random.randint(0, 99999):05d}", + "model_name_2": f"test-model-name-{random.randint(0, 99999):05d}", + "model_version": "1", + } + test_files_params = { "test_file_name": "test_file.jsonl", "file_purpose": "fine-tune", diff --git a/sdk/ai/azure-ai-projects/tests/test_data/models/config.json b/sdk/ai/azure-ai-projects/tests/test_data/models/config.json new file mode 100644 index 000000000000..a60e008113c0 --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/test_data/models/config.json @@ -0,0 +1 @@ +{"sample": true} diff --git a/sdk/ai/azure-ai-projects/tests/test_data/models/weights.bin b/sdk/ai/azure-ai-projects/tests/test_data/models/weights.bin new file mode 100644 index 000000000000..06a01532107f --- /dev/null +++ b/sdk/ai/azure-ai-projects/tests/test_data/models/weights.bin @@ -0,0 +1 @@ +test-weight-bytes