Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/aimanager/HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
Release History
===============

1.2.1b1
+++++++
* Add ``az aimanager model`` commands to list and show AI models in a region, and to
``calculate-cost`` for deploying a model.

1.2.0
++++++
* Add ``az aimanager namespace modeldeployment`` commands to add, update, list, show, delete,
Expand Down
2 changes: 1 addition & 1 deletion src/aimanager/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ AI Manager
==========

Manage AI Manager resources, namespaces, and GPU-backed model deployments for
Azure Kubernetes Service (AKS).
Azure Kubernetes Service (AKS), and browse the regional AI model catalog.
4 changes: 4 additions & 0 deletions src/aimanager/azext_aimanager/_client_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,7 @@ def cf_ai_manager_namespaces(cli_ctx, *_):

def cf_model_deployments(cli_ctx, *_):
return get_aimanager_client(cli_ctx).model_deployments


def cf_ai_models(cli_ctx, *_):
return get_aimanager_client(cli_ctx).ai_models
44 changes: 44 additions & 0 deletions src/aimanager/azext_aimanager/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,47 @@
type: command
short-summary: Wait for an AI Manager model deployment to reach a desired state.
"""

helps['aimanager model'] = """
type: group
short-summary: Browse the AI model catalog and estimate deployment cost.
long-summary: |-
AI models are read-only, platform-maintained catalog entries scoped to an Azure region.
Use 'az aimanager model list' to discover the models available in a region and their
resource names, which can then be passed to
'az aimanager namespace modeldeployment add --model-resource-id'.
"""

helps['aimanager model show'] = """
type: command
short-summary: Show the details of an AI model in the catalog.
examples:
- name: Show an AI model
text: az aimanager model show -l eastus2 -n 9806f0c862fdd920
"""

helps['aimanager model list'] = """
type: command
short-summary: List the AI models available in a region.
examples:
- name: List the AI models in a region
text: az aimanager model list -l eastus2
- name: List the AI models in a region as a table
text: az aimanager model list -l eastus2 -o table
"""

helps['aimanager model calculate-cost'] = """
type: command
short-summary: Calculate the estimated cost of deploying an AI model in a region.
long-summary: |-
Returns a ranked list of GPU SKU pricing plans for deploying the model in the target
region, each annotated with feasibility, per-replica hourly cost, and estimated relative
performance. Feasible plans are returned first, ordered by total hourly price ascending.
No Azure or Kubernetes resources are provisioned by this command. Prices describe a single
replica; multiply by the desired replica count, bounded by maxAvailableReplicas.
examples:
- name: Calculate the cost of deploying a model
text: az aimanager model calculate-cost -l eastus2 -n 9806f0c862fdd920
- name: Show the pricing plans as a table
text: az aimanager model calculate-cost -l eastus2 -n 9806f0c862fdd920 -o table
"""
12 changes: 12 additions & 0 deletions src/aimanager/azext_aimanager/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
validate_ai_manager_name,
validate_namespace_name,
validate_model_deployment_name,
validate_ai_model_name,
validate_labels,
validate_annotations,
validate_overrides,
Expand Down Expand Up @@ -117,3 +118,14 @@ def load_arguments(self, _):
help='Space-separated experimental deployment overrides (key=value).')
c.argument('aks_custom_headers', options_list=['--aks-custom-headers'],
help='Comma-separated key=value pairs to specify custom headers.')

with self.argument_context('aimanager model') as c:
c.argument('location', arg_type=get_location_type(self.cli_ctx), required=True,
help='The Azure region hosting the AI model catalog.')
c.argument('ai_model_name', options_list=['--name', '-n'],
validator=validate_ai_model_name,
help='The name of the AI model. This is an opaque, stable identifier derived '
'from the model ID; use "az aimanager model list" to discover it.')

with self.argument_context('aimanager model list') as c:
c.ignore('ai_model_name')
6 changes: 6 additions & 0 deletions src/aimanager/azext_aimanager/_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ def validate_model_deployment_name(namespace):
raise InvalidArgumentValueError("--name/-n is not a valid model deployment name.")


def validate_ai_model_name(namespace):
name = getattr(namespace, "ai_model_name", None)
if name is not None and not name.strip():
raise InvalidArgumentValueError("--name/-n is not a valid AI model name.")


def _validate_key_value_pairs(values, option):
if not values:
return
Expand Down
2 changes: 1 addition & 1 deletion src/aimanager/azext_aimanager/azext_metadata.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"azext.isPreview": true,
"azext.minCliCoreVersion": "2.61.0",
"version": "1.2.0"
"version": "1.2.1b1"
}
19 changes: 19 additions & 0 deletions src/aimanager/azext_aimanager/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@

# pylint: disable=line-too-long
from azure.cli.core.commands import CliCommandType
from azext_aimanager.constants import (
AI_MODEL_TABLE_TRANSFORMER,
CALCULATE_COST_TABLE_TRANSFORMER,
)
from azext_aimanager._client_factory import (
cf_ai_managers,
cf_ai_manager_namespaces,
cf_model_deployments,
cf_ai_models,
)


Expand All @@ -32,6 +37,12 @@ def load_command_table(self, _):
client_factory=cf_model_deployments
)

ai_models_sdk = CliCommandType(
operations_tmpl="azext_aimanager.vendored_sdks.v2026_05_02_preview.operations._operations#AIModelsOperations.{}",
operation_group="ai_models",
client_factory=cf_ai_models
)

# aimanager command group
with self.command_group("aimanager", ai_managers_sdk, client_factory=cf_ai_managers) as g:
g.custom_command("create", "create_aimanager", supports_no_wait=True)
Expand Down Expand Up @@ -61,3 +72,11 @@ def load_command_table(self, _):
g.custom_command("list", "list_modeldeployment")
g.custom_command("delete", "delete_modeldeployment", supports_no_wait=True, confirmation=True)
g.custom_wait_command("wait", "show_modeldeployment")

# aimanager model command group
with self.command_group("aimanager model", ai_models_sdk, client_factory=cf_ai_models) as g:
g.custom_show_command("show", "show_aimodel")
g.custom_command("list", "list_aimodel",
table_transformer=AI_MODEL_TABLE_TRANSFORMER)
g.custom_command("calculate-cost", "calculate_aimodel_cost",
table_transformer=CALCULATE_COST_TABLE_TRANSFORMER)
11 changes: 11 additions & 0 deletions src/aimanager/azext_aimanager/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,14 @@
DELETE_POLICIES = [DELETE_POLICY_KEEP, DELETE_POLICY_DELETE]

MODEL_DEPLOYMENT_PERFORMANCE_MODES = ["Balanced", "Latency", "Throughput"]

# Table output projections for the 'az aimanager model' commands.
AI_MODEL_TABLE_TRANSFORMER = (
"[].{Name:name, ModelId:properties.modelId, Description:properties.description}"
)

CALCULATE_COST_TABLE_TRANSFORMER = (
"plans[].{VmSize:vmSize, Feasible:feasible, VmsPerReplica:vmsPerReplica, "
"VmHourlyPrice:vmHourlyPrice, TotalHourlyPrice:totalHourlyPrice, "
"MaxAvailableReplicas:maxAvailableReplicas, Quantization:quantization}"
)
17 changes: 17 additions & 0 deletions src/aimanager/azext_aimanager/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,3 +451,20 @@ def delete_modeldeployment(cmd, client, resource_group_name, ai_manager_name, na
namespace_name, model_deployment_name)

# endregion


# region AI model

def show_aimodel(cmd, client, location, ai_model_name): # pylint: disable=unused-argument
return client.get(location, ai_model_name)


def list_aimodel(cmd, client, location): # pylint: disable=unused-argument
return client.list(location)


def calculate_aimodel_cost(cmd, client, location, ai_model_name):
request_model = _get_model(cmd, "CalculateCostRequest", "ai_models")
return client.calculate_cost(location, ai_model_name, request_model())

# endregion
74 changes: 74 additions & 0 deletions src/aimanager/azext_aimanager/tests/latest/test_aimodel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock

from azure.cli.core.azclierror import InvalidArgumentValueError

from azext_aimanager import custom
from azext_aimanager._validators import validate_ai_model_name
from azext_aimanager.vendored_sdks.v2026_05_02_preview import models


class MockCmd:
def get_models(self, name, **_):
return getattr(models, name)


class TestAIModel(unittest.TestCase):

def setUp(self):
self.cmd = MockCmd()
self.client = MagicMock()

def test_show_aimodel(self):
self.client.get.return_value = "model"

result = custom.show_aimodel(self.cmd, self.client, "eastus2", "9806f0c862fdd920")

self.assertEqual(result, "model")
self.client.get.assert_called_once_with("eastus2", "9806f0c862fdd920")

def test_list_aimodel(self):
self.client.list.return_value = ["model"]

result = custom.list_aimodel(self.cmd, self.client, "eastus2")

self.assertEqual(result, ["model"])
self.client.list.assert_called_once_with("eastus2")

def test_calculate_aimodel_cost_sends_empty_request_body(self):
self.client.calculate_cost.return_value = "plans"

result = custom.calculate_aimodel_cost(
self.cmd, self.client, "eastus2", "9806f0c862fdd920")

self.assertEqual(result, "plans")
self.client.calculate_cost.assert_called_once()
location, ai_model_name, body = self.client.calculate_cost.call_args[0]
self.assertEqual(location, "eastus2")
self.assertEqual(ai_model_name, "9806f0c862fdd920")
self.assertIsInstance(body, models.CalculateCostRequest)
self.assertEqual(dict(body), {})


class TestAIModelValidators(unittest.TestCase):

def test_valid_name(self):
validate_ai_model_name(SimpleNamespace(ai_model_name="9806f0c862fdd920"))

def test_missing_name_is_allowed(self):
validate_ai_model_name(SimpleNamespace(ai_model_name=None))
validate_ai_model_name(SimpleNamespace())

def test_blank_name_is_rejected(self):
with self.assertRaises(InvalidArgumentValueError):
validate_ai_model_name(SimpleNamespace(ai_model_name=" "))


if __name__ == '__main__':
unittest.main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

from unittest.mock import MagicMock, patch

from azure.cli.testsdk import ScenarioTest

from azext_aimanager.vendored_sdks.v2026_05_02_preview import models


class AIModelScenarioTest(ScenarioTest):

def test_aimodel_commands(self):
ai_model = models.AIModel({
'name': '9806f0c862fdd920',
'properties': {
'modelId': 'microsoft/Phi-4-mini-instruct',
'description': 'A small language model.',
'spec': {'contextLength': 131072},
},
})
calculate_cost_response = models.CalculateCostResponse({
'currency': 'USD',
'plans': [
{
'vmSize': 'Standard_NC24ads_A100_v4',
'quantization': 'fp8',
'vmsPerReplica': 1,
'maxAvailableReplicas': 4,
'vmHourlyPrice': 3.67,
'totalHourlyPrice': 3.67,
'feasible': True,
},
{
'vmSize': 'Standard_ND96isr_H100_v5',
'vmsPerReplica': 1,
'maxAvailableReplicas': 0,
'vmHourlyPrice': 98.32,
'feasible': False,
'infeasibilityReason': {'code': 'InsufficientQuota'},
},
],
})

operations = MagicMock()
operations.get.return_value = ai_model
operations.list.return_value = [ai_model]
operations.calculate_cost.return_value = calculate_cost_response
service_client = MagicMock()
service_client.ai_models = operations

with patch('azext_aimanager._client_factory.get_aimanager_client',
return_value=service_client):
self.cmd(
'aimanager model show -l eastus2 -n 9806f0c862fdd920',
checks=[
self.check('name', '9806f0c862fdd920'),
self.check('properties.modelId', 'microsoft/Phi-4-mini-instruct'),
])

self.cmd(
'aimanager model list -l eastus2',
checks=[self.check("length([?name=='9806f0c862fdd920'])", 1)])

self.cmd(
'aimanager model calculate-cost -l eastus2 -n 9806f0c862fdd920',
checks=[
self.check('currency', 'USD'),
self.check('length(plans)', 2),
self.check('plans[0].vmSize', 'Standard_NC24ads_A100_v4'),
self.check('plans[0].feasible', True),
self.check('plans[0].totalHourlyPrice', 3.67),
self.check('plans[1].feasible', False),
self.check('plans[1].infeasibilityReason.code', 'InsufficientQuota'),
])

operations.get.assert_called_once_with('eastus2', '9806f0c862fdd920')
operations.list.assert_called_once_with('eastus2')
operations.calculate_cost.assert_called_once()
self.assertEqual(
operations.calculate_cost.call_args[0][:2], ('eastus2', '9806f0c862fdd920'))
2 changes: 1 addition & 1 deletion src/aimanager/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from distutils import log as logger
logger.warn("Wheel is not available, disabling bdist_wheel hook")

VERSION = '1.2.0'
VERSION = '1.2.1b1'

# The full list of classifiers is available at
# https://pypi.python.org/pypi?%3Aaction=list_classifiers
Expand Down
Loading