diff --git a/src/aimanager/HISTORY.rst b/src/aimanager/HISTORY.rst index f3812405cfd..c73c9c9c9b0 100644 --- a/src/aimanager/HISTORY.rst +++ b/src/aimanager/HISTORY.rst @@ -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, diff --git a/src/aimanager/README.rst b/src/aimanager/README.rst index 505f3f1c9a6..8a6b8ce04d0 100644 --- a/src/aimanager/README.rst +++ b/src/aimanager/README.rst @@ -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. diff --git a/src/aimanager/azext_aimanager/_client_factory.py b/src/aimanager/azext_aimanager/_client_factory.py index 4a6351168ef..c954a2f1690 100644 --- a/src/aimanager/azext_aimanager/_client_factory.py +++ b/src/aimanager/azext_aimanager/_client_factory.py @@ -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 diff --git a/src/aimanager/azext_aimanager/_help.py b/src/aimanager/azext_aimanager/_help.py index 4964224cc91..211cb0fc147 100644 --- a/src/aimanager/azext_aimanager/_help.py +++ b/src/aimanager/azext_aimanager/_help.py @@ -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 +""" diff --git a/src/aimanager/azext_aimanager/_params.py b/src/aimanager/azext_aimanager/_params.py index b859b8577de..16800dde306 100644 --- a/src/aimanager/azext_aimanager/_params.py +++ b/src/aimanager/azext_aimanager/_params.py @@ -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, @@ -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') diff --git a/src/aimanager/azext_aimanager/_validators.py b/src/aimanager/azext_aimanager/_validators.py index b7423de3a1e..ea536c3f406 100644 --- a/src/aimanager/azext_aimanager/_validators.py +++ b/src/aimanager/azext_aimanager/_validators.py @@ -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 diff --git a/src/aimanager/azext_aimanager/azext_metadata.json b/src/aimanager/azext_aimanager/azext_metadata.json index 8ad409021bd..bda33e00008 100644 --- a/src/aimanager/azext_aimanager/azext_metadata.json +++ b/src/aimanager/azext_aimanager/azext_metadata.json @@ -1,5 +1,5 @@ { "azext.isPreview": true, "azext.minCliCoreVersion": "2.61.0", - "version": "1.2.0" + "version": "1.2.1b1" } diff --git a/src/aimanager/azext_aimanager/commands.py b/src/aimanager/azext_aimanager/commands.py index d100c263b27..6d529534be8 100644 --- a/src/aimanager/azext_aimanager/commands.py +++ b/src/aimanager/azext_aimanager/commands.py @@ -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, ) @@ -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) @@ -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) diff --git a/src/aimanager/azext_aimanager/constants.py b/src/aimanager/azext_aimanager/constants.py index edebdcfabfe..fd9388a4874 100644 --- a/src/aimanager/azext_aimanager/constants.py +++ b/src/aimanager/azext_aimanager/constants.py @@ -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}" +) diff --git a/src/aimanager/azext_aimanager/custom.py b/src/aimanager/azext_aimanager/custom.py index 6da27d25606..17a3b4de492 100644 --- a/src/aimanager/azext_aimanager/custom.py +++ b/src/aimanager/azext_aimanager/custom.py @@ -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 diff --git a/src/aimanager/azext_aimanager/tests/latest/test_aimodel.py b/src/aimanager/azext_aimanager/tests/latest/test_aimodel.py new file mode 100644 index 00000000000..d5372f506e8 --- /dev/null +++ b/src/aimanager/azext_aimanager/tests/latest/test_aimodel.py @@ -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() diff --git a/src/aimanager/azext_aimanager/tests/latest/test_aimodel_scenario.py b/src/aimanager/azext_aimanager/tests/latest/test_aimodel_scenario.py new file mode 100644 index 00000000000..88ff64d8ba1 --- /dev/null +++ b/src/aimanager/azext_aimanager/tests/latest/test_aimodel_scenario.py @@ -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')) diff --git a/src/aimanager/setup.py b/src/aimanager/setup.py index 399850f3d04..11bac3528e6 100644 --- a/src/aimanager/setup.py +++ b/src/aimanager/setup.py @@ -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