Skip to content

Commit be28736

Browse files
SDK operations for Models, Unit tests and Sample notebook (#46842)
* SDK operations for Models, Unit tests and Sample notebook * modifying sample to .py instead of .ipynb and update changelog * post emitter fixes and resolving review comments * fix cpell - azcopy * re emit from typespec for PendingUploadType changes * reverting pyproject.toml * pulling base branch of foundry sdk release for build * Revert aio _patch.py to base; minimize sync _patch.py diff for BetaModelsOperations * Address PR #46842 review comments Darren (dargilco): - D1: Merge feature/azure-ai-projects/2.2.0 into branch (separate merge commit). - D2: Remove duplicate '.beta.models' CHANGELOG bullet. - D3: Remove stray blank line in CHANGELOG. - D4: Revert apiview-properties.json to base. - D5: Wire async patched BetaModelsOperations into aio/operations/_patch.py; add azure/ai/projects/aio/operations/_patch_models_async.py with async create_version() using azure.storage.blob.aio.ContainerClient. Howie (howieleung): - H1: Rename helper models_create -> create_version (and validator). - H2: Rename sample_models{,_async}.py -> sample_models_basic{,_async}.py. - H3: Print friendly per-field summaries in samples instead of raw model repr. - H4: Guard against None return from create_version before accessing fields. Also follows TypeSpec rename: generated create_async() -> pending_create_version(). * Rename .beta.models patched helper create_version -> create - Renames patched BetaModelsOperations.create_version() to create() (sync + async). - Renames internal validator _validate_create_version_inputs -> _validate_create_inputs. - Updates samples, tests, README, and CHANGELOG to use the new name. - Leaves generated spec method pending_create_version unchanged. * Add sample recordings for .beta.models and fix generated arg names - Add parameterized sample tests test_models_samples (sync + async). - Add slim modelsServicePreparer (only foundry_project_endpoint). - Add sanitizers for random model name, Foundry storage account/container, /workspaces/<name>, azureai:// asset URIs, account/project from FOUNDRY_PROJECT_ENDPOINT, and SAS query parameters (sig, skoid, sktid). - Use per-recording random MODEL_NAME (recsmplmdl<hex>) and sanitize to a stable value on playback (Foundry asset-store reserves <name>/<version> permanently). - Update assets.json tag to point at the new recordings. Bug fixes surfaced by live runs (renamed generated kwargs): - pending_upload: body= -> pending_upload_request= - pending_create_version: body= -> model_version= - get_credentials: body= -> credential_request= Applied to patched create() helper (sync + async), all three model samples, and the test_patch_models mock. Notes: - sample_models_basic.py (which uses AzCopy) is excluded from the parameterized sample tests because AzCopy traffic isn't captured by the test proxy. - LLM print-call validation is not invoked for models tests (canary project has no Azure OpenAI connection). * Exclude .beta.models.create from foundry-features header test The multi-step orchestrator helper performs local input validation before any HTTP call, so the test framework's synthetic placeholder args (e.g. source={}) cause it to raise TypeError before _RequestCaptured is ever raised. Add a shared EXCLUDED_BETA_METHODS mapping and skip excluded methods in both sync and async discovery. The header invariant is still enforced for every underlying HTTP method create() calls (pending_upload, pending_create_version, get). * Add cspell entries: recsmplmdl, simpleqna, skoid * Fix pyright and pylint issues in BetaModelsOperations patches - Replace hasattr-based duck typing with isinstance(dict) check so pyright can narrow types correctly on _extract_pending_upload_targets. - Remove unused HttpResponseError import. - Suppress do-not-import-asyncio for asyncio.sleep used in the async polling loop (transport sleep is not applicable). - Add :param/:keyword/:return/:rtype docstring sections on private helpers to satisfy azure-pylint-guidelines-checker. * Add @overload variants to .beta.models.create for precise return types * Rename sample_models_without_patch.py to sample_models_create_and_poll.py * Remove redundant 'if model is None' check in sample_models_basic.py (covered by overload)
1 parent 22cc799 commit be28736

23 files changed

Lines changed: 2026 additions & 6 deletions

sdk/ai/azure-ai-projects/CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
* New optional `force` parameter on `agents.delete` and `agents.delete_version` methods.
2121
* New optional `blueprint_reference` parameters on `agents.create_version` method.
2222
* 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.
23-
23+
* 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.
2424

2525
### Breaking Changes
2626

@@ -52,6 +52,10 @@ Breaking changes in beta classes:
5252
* 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.
5353
* 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.
5454
* 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`.
55+
* Added `.beta.models` samples under `samples/models/`:
56+
* `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`.
57+
* `sample_models_create_and_poll.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`.
58+
* `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`.
5559

5660
## 2.1.0 (2026-04-20)
5761

sdk/ai/azure-ai-projects/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ resources in your Microsoft Foundry Project. Use it to:
3535
* **Enumerate AI Models** deployed to your Foundry Project using `.deployments` operations.
3636
* **Enumerate connected Azure resources** in your Foundry project using `.connections` operations.
3737
* **Upload documents and create Datasets** to reference them using `.datasets` operations.
38+
* **Register and manage local model weights** as Foundry `ModelVersion` resources using `.beta.models` operations, including the `create` end-to-end helper.
3839
* **Create and enumerate Search Indexes** using `.indexes` operations.
3940

4041
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:
166167
| Deployments | [Deployment types](https://learn.microsoft.com/azure/foundry/foundry-models/concepts/deployment-types) | `samples/deployments/` |
167168
| Connections | [Connections operations](https://learn.microsoft.com/python/api/overview/azure/ai-projects-readme?view=azure-python#connections-operations) | `samples/connections/` |
168169
| Datasets | [Dataset operations](https://learn.microsoft.com/python/api/overview/azure/ai-projects-readme?view=azure-python#dataset-operations) | `samples/datasets/` |
170+
| 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/` |
169171
| Indexes | [Azure AI Search](https://learn.microsoft.com/azure/search/search-what-is-azure-search) | `samples/indexes/` |
170172
| Files (upload, retrieve, list, delete) | [OpenAI Files API](https://platform.openai.com/docs/api-reference/files) | `samples/files/` |
171173
| Fine-tuning | [Fine-Tuning in AI Foundry](https://github.com/microsoft-foundry/fine-tuning) | `samples/finetuning/` |

sdk/ai/azure-ai-projects/assets.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
"AssetsRepo": "Azure/azure-sdk-assets",
33
"AssetsRepoPrefixPath": "python",
44
"TagPrefix": "python/ai/azure-ai-projects",
5-
"Tag": "python/ai/azure-ai-projects_b13a910d61"
5+
"Tag": "python/ai/azure-ai-projects_7fc443e02a"
66
}

sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@
1515
from ._patch_telemetry_async import TelemetryOperations
1616
from ._patch_connections_async import ConnectionsOperations
1717
from ._patch_memories_async import BetaMemoryStoresOperations
18+
from ._patch_models_async import BetaModelsOperations
1819
from ._patch_sessions_async import BetaAgentsOperations
1920
from ...operations._patch import _BETA_OPERATION_FEATURE_HEADERS, _OperationMethodHeaderProxy
2021
from ._operations import (
2122
BetaDatasetsOperations,
2223
BetaEvaluationTaxonomiesOperations,
2324
BetaEvaluatorsOperations,
2425
BetaInsightsOperations,
25-
BetaModelsOperations,
2626
BetaOperations as GeneratedBetaOperations,
2727
BetaRedTeamsOperations,
2828
BetaRoutinesOperations,
@@ -75,6 +75,8 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
7575
self.agents = BetaAgentsOperations(self._client, self._config, self._serialize, self._deserialize)
7676
# Replace with patched class that includes begin_update_memories
7777
self.memory_stores = BetaMemoryStoresOperations(self._client, self._config, self._serialize, self._deserialize)
78+
# Replace with patched class that includes create (3-step upload helper)
79+
self.models = BetaModelsOperations(self._client, self._config, self._serialize, self._deserialize)
7880

7981
for property_name, foundry_features_value in _BETA_OPERATION_FEATURE_HEADERS.items():
8082
setattr(
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
# pylint: disable=line-too-long,useless-suppression
2+
# ------------------------------------
3+
# Copyright (c) Microsoft Corporation.
4+
# Licensed under the MIT License.
5+
# ------------------------------------
6+
"""Customize generated code here.
7+
8+
Follow our quickstart for examples: https://aka.ms/azsdk/python/dpcodegen/python/customize
9+
"""
10+
11+
import asyncio # pylint: disable=do-not-import-asyncio
12+
import logging
13+
import os
14+
from pathlib import Path
15+
from typing import Any, Literal, Optional, Union, overload
16+
17+
from azure.core.exceptions import ResourceNotFoundError
18+
from azure.core.tracing.decorator_async import distributed_trace_async
19+
20+
from ._operations import BetaModelsOperations as BetaModelsOperationsGenerated
21+
from ...models._models import (
22+
ModelPendingUploadRequest,
23+
ModelPendingUploadResponse,
24+
ModelVersion,
25+
PendingUploadType,
26+
)
27+
28+
logger = logging.getLogger(__name__)
29+
30+
31+
class BetaModelsOperations(BetaModelsOperationsGenerated):
32+
"""
33+
.. warning::
34+
**DO NOT** instantiate this class directly.
35+
36+
Instead, you should access the following operations through
37+
:class:`~azure.ai.projects.aio.AIProjectClient`'s
38+
:attr:`beta.models <azure.ai.projects.aio.operations.BetaOperations.models>` attribute.
39+
"""
40+
41+
@staticmethod
42+
def _extract_pending_upload_targets(
43+
response: Union[ModelPendingUploadResponse, dict],
44+
) -> "tuple[str, str, Optional[str]]":
45+
"""Return ``(sas_uri, container_blob_uri, pending_upload_id)`` from a pending-upload response.
46+
47+
The service currently returns the raw datastore-style payload
48+
(``blobReferenceForConsumption`` / ``temporaryDataReferenceId``) for some
49+
Foundry deployments rather than the SDK-modeled ``ModelPendingUploadResponse``
50+
shape (``blobReference`` / ``pendingUploadId``). Tolerate both wire
51+
shapes so callers don't have to.
52+
53+
:param response: The pending-upload response from the service.
54+
:type response: ~azure.ai.projects.models.ModelPendingUploadResponse or dict
55+
:return: A tuple of ``(sas_uri, container_blob_uri, pending_upload_id)``.
56+
:rtype: tuple[str, str, str or None]
57+
"""
58+
payload = dict(response) if isinstance(response, dict) else response.as_dict()
59+
60+
blob_ref = payload.get("blobReferenceForConsumption") or payload.get("blobReference") or {}
61+
sas_uri = (blob_ref.get("credential") or {}).get("sasUri")
62+
container_blob_uri = blob_ref.get("blobUri")
63+
pending_upload_id = payload.get("temporaryDataReferenceId") or payload.get("pendingUploadId")
64+
65+
if not sas_uri or not container_blob_uri:
66+
raise ValueError("Could not locate SAS URI / blob URI in pending_upload response: " f"{payload!r}")
67+
return sas_uri, container_blob_uri, pending_upload_id
68+
69+
@staticmethod
70+
def _validate_create_inputs(
71+
*,
72+
name: str,
73+
version: str,
74+
source: Union[str, "os.PathLike[str]"],
75+
wait_for_commit: bool,
76+
polling_timeout: float,
77+
polling_interval: float,
78+
) -> Path:
79+
"""Validate ``create`` inputs up-front, before any service call.
80+
81+
Returns the resolved ``Path`` for ``source``. Raises ``ValueError`` for
82+
bad inputs.
83+
84+
:keyword name: Name of the model to register.
85+
:paramtype name: str
86+
:keyword version: Version identifier for the model.
87+
:paramtype version: str
88+
:keyword source: Local file or directory containing the model weights.
89+
:paramtype source: str or os.PathLike[str]
90+
:keyword wait_for_commit: Whether to poll for commit completion.
91+
:paramtype wait_for_commit: bool
92+
:keyword polling_timeout: Total seconds to poll for commit completion.
93+
:paramtype polling_timeout: float
94+
:keyword polling_interval: Seconds between poll attempts.
95+
:paramtype polling_interval: float
96+
:return: The resolved ``Path`` for ``source``.
97+
:rtype: pathlib.Path
98+
"""
99+
if not isinstance(name, str) or not name.strip():
100+
raise ValueError("`name` must be a non-empty string.")
101+
if not isinstance(version, str) or not version.strip():
102+
raise ValueError("`version` must be a non-empty string.")
103+
104+
source_path = Path(os.fspath(source))
105+
if not source_path.exists():
106+
raise ValueError(f"Upload source does not exist: {source_path}")
107+
if source_path.is_dir() and not any(p.is_file() for p in source_path.rglob("*")):
108+
raise ValueError(f"Upload source directory is empty: {source_path}")
109+
if source_path.is_file() and source_path.stat().st_size == 0:
110+
raise ValueError(f"Upload source file is empty: {source_path}")
111+
112+
if wait_for_commit:
113+
if polling_timeout <= 0:
114+
raise ValueError("`polling_timeout` must be > 0 when `wait_for_commit` is True.")
115+
if polling_interval <= 0:
116+
raise ValueError("`polling_interval` must be > 0 when `wait_for_commit` is True.")
117+
118+
return source_path
119+
120+
@staticmethod
121+
async def _upload_with_container_client(source: Path, sas_uri: str) -> None:
122+
"""Upload ``source`` to the SAS container using ``azure.storage.blob.aio.ContainerClient``.
123+
124+
:param source: Local file or directory to upload.
125+
:type source: pathlib.Path
126+
:param sas_uri: SAS URI for the destination container.
127+
:type sas_uri: str
128+
:raises RuntimeError: If ``azure-storage-blob`` is not installed.
129+
"""
130+
try:
131+
from azure.storage.blob.aio import ContainerClient # pylint: disable=import-outside-toplevel
132+
except ImportError as ex:
133+
raise RuntimeError(
134+
"`azure-storage-blob` is required for the async `create` helper. "
135+
"Install it with `pip install azure-storage-blob aiohttp`."
136+
) from ex
137+
138+
if source.is_dir():
139+
files = [p for p in source.rglob("*") if p.is_file()]
140+
if not files:
141+
raise ValueError(f"Upload source directory is empty: {source}")
142+
elif source.is_file():
143+
files = [source]
144+
else:
145+
raise ValueError(f"Upload source does not exist: {source}")
146+
147+
# Don't log the SAS query string — it's a credential.
148+
redacted = sas_uri.split("?", 1)[0] + "?<sas-redacted>"
149+
logger.info("[create] uploading %d file(s) to %s", len(files), redacted)
150+
151+
async with ContainerClient.from_container_url(sas_uri) as container_client:
152+
for f in files:
153+
rel = f.relative_to(source).as_posix() if source.is_dir() else f.name
154+
with f.open("rb") as fp:
155+
await container_client.upload_blob(name=rel, data=fp, overwrite=True)
156+
logger.debug("[create] uploaded %s (%d bytes)", rel, f.stat().st_size)
157+
158+
@overload
159+
async def create(
160+
self,
161+
*,
162+
name: str,
163+
version: str,
164+
source: Union[str, "os.PathLike[str]"],
165+
weight_type: Optional[str] = None,
166+
base_model: Optional[str] = None,
167+
description: Optional[str] = None,
168+
tags: Optional["dict[str, str]"] = None,
169+
wait_for_commit: Literal[True] = True,
170+
polling_timeout: float = 300.0,
171+
polling_interval: float = 2.0,
172+
**kwargs: Any,
173+
) -> ModelVersion:
174+
...
175+
176+
@overload
177+
async def create(
178+
self,
179+
*,
180+
name: str,
181+
version: str,
182+
source: Union[str, "os.PathLike[str]"],
183+
weight_type: Optional[str] = None,
184+
base_model: Optional[str] = None,
185+
description: Optional[str] = None,
186+
tags: Optional["dict[str, str]"] = None,
187+
wait_for_commit: Literal[False],
188+
polling_timeout: float = 300.0,
189+
polling_interval: float = 2.0,
190+
**kwargs: Any,
191+
) -> None:
192+
...
193+
194+
@distributed_trace_async
195+
async def create(
196+
self,
197+
*,
198+
name: str,
199+
version: str,
200+
source: Union[str, "os.PathLike[str]"],
201+
weight_type: Optional[str] = None,
202+
base_model: Optional[str] = None,
203+
description: Optional[str] = None,
204+
tags: Optional["dict[str, str]"] = None,
205+
wait_for_commit: bool = True,
206+
polling_timeout: float = 300.0,
207+
polling_interval: float = 2.0,
208+
**kwargs: Any,
209+
) -> Optional[ModelVersion]:
210+
"""Register a local model by running the full upload-first sequence (async).
211+
212+
This wraps the three mandatory steps of the model-registration spec
213+
into a single call:
214+
215+
1. :meth:`pending_upload` — provision a project-managed blob container
216+
and obtain a SAS URI.
217+
2. Upload the local weight files to the SAS container using
218+
:class:`azure.storage.blob.aio.ContainerClient`.
219+
3. :meth:`pending_create_version` — finalize registration with the
220+
``ModelVersion`` body (``blob_uri``, ``weight_type``, ``base_model``,
221+
``description``, ``tags``).
222+
223+
Requires the ``azure-storage-blob`` package (with ``aiohttp``) for the
224+
upload step.
225+
226+
:keyword name: Name of the model to register. Required.
227+
:paramtype name: str
228+
:keyword version: Version identifier for the model. Required.
229+
:paramtype version: str
230+
:keyword source: Local file or directory containing the model weights.
231+
If a directory, its contents are uploaded recursively to the SAS
232+
container root. Required.
233+
:paramtype source: str or os.PathLike[str]
234+
:keyword weight_type: Optional weight type (e.g. ``"FullWeight"``,
235+
``"LoRA"``, ``"DraftModel"``).
236+
:paramtype weight_type: str
237+
:keyword base_model: Optional base model asset ID.
238+
:paramtype base_model: str
239+
:keyword description: Optional asset description.
240+
:paramtype description: str
241+
:keyword tags: Optional asset tags.
242+
:paramtype tags: dict[str, str]
243+
:keyword wait_for_commit: When True (default) poll :meth:`get` until
244+
the committed ``ModelVersion`` is observable, and return it.
245+
When False, return ``None`` after the async commit is accepted.
246+
:paramtype wait_for_commit: bool
247+
:keyword polling_timeout: Total seconds to poll for commit completion.
248+
:paramtype polling_timeout: float
249+
:keyword polling_interval: Seconds between poll attempts.
250+
:paramtype polling_interval: float
251+
:return: The committed :class:`~azure.ai.projects.models.ModelVersion`
252+
when ``wait_for_commit`` is True, otherwise ``None``.
253+
:rtype: ~azure.ai.projects.models.ModelVersion or None
254+
:raises ValueError: If ``name``/``version`` are empty, ``source`` does
255+
not exist or is empty, polling parameters are non-positive, or the
256+
pending-upload response is missing the SAS / blob URI.
257+
:raises RuntimeError: If ``azure-storage-blob`` is not installed or
258+
the registration does not commit before ``polling_timeout`` elapses.
259+
"""
260+
# --- Step 0: validate inputs up-front --------------------------------
261+
source_path = self._validate_create_inputs(
262+
name=name,
263+
version=version,
264+
source=source,
265+
wait_for_commit=wait_for_commit,
266+
polling_timeout=polling_timeout,
267+
polling_interval=polling_interval,
268+
)
269+
270+
# --- Step 1: StartPendingUpload --------------------------------------
271+
logger.info(
272+
"[create] step 1/3 pending_upload(name=%r, version=%r)",
273+
name,
274+
version,
275+
)
276+
pending = await self.pending_upload(
277+
name=name,
278+
version=version,
279+
pending_upload_request=ModelPendingUploadRequest(
280+
pending_upload_type=PendingUploadType.TEMPORARY_BLOB_REFERENCE,
281+
),
282+
**kwargs,
283+
)
284+
sas_uri, container_blob_uri, pending_upload_id = self._extract_pending_upload_targets(pending)
285+
logger.info(
286+
"[create] pending_upload_id=%s blob_uri=%s",
287+
pending_upload_id,
288+
container_blob_uri,
289+
)
290+
291+
# --- Step 2: Upload via async ContainerClient ------------------------
292+
logger.info("[create] step 2/3 async upload from %s", source_path)
293+
await self._upload_with_container_client(source_path, sas_uri)
294+
295+
# --- Step 3: Commit registration -------------------------------------
296+
model_version_body = ModelVersion(
297+
blob_uri=container_blob_uri,
298+
weight_type=weight_type,
299+
base_model=base_model,
300+
description=description,
301+
tags=tags or {},
302+
)
303+
logger.info(
304+
"[create] step 3/3 pending_create_version(name=%r, version=%r)",
305+
name,
306+
version,
307+
)
308+
await self.pending_create_version(name=name, version=version, model_version=model_version_body, **kwargs)
309+
310+
if not wait_for_commit:
311+
return None
312+
313+
# The async op returns 202; the service materializes the ModelVersion
314+
# asynchronously. Poll get() until it appears or we time out.
315+
import time # pylint: disable=import-outside-toplevel
316+
317+
deadline = time.monotonic() + polling_timeout
318+
last_exc: Optional[BaseException] = None
319+
while True:
320+
try:
321+
return await self.get(name=name, version=version, **kwargs)
322+
except ResourceNotFoundError as ex:
323+
last_exc = ex
324+
if time.monotonic() >= deadline:
325+
raise RuntimeError(
326+
f"Model {name!r}@{version!r} did not appear within " f"{polling_timeout}s after pending_create_version."
327+
) from last_exc
328+
await asyncio.sleep(polling_interval)
329+
330+
331+
__all__ = ["BetaModelsOperations"]

0 commit comments

Comments
 (0)