diff --git a/src/aimanager/HISTORY.rst b/src/aimanager/HISTORY.rst index 1ed35efcd77..b503e2b9394 100644 --- a/src/aimanager/HISTORY.rst +++ b/src/aimanager/HISTORY.rst @@ -3,6 +3,16 @@ Release History =============== +1.5.3b1 +++++++ +* ``az aimanager list`` and ``az aimanager show``: Improve ``-o table`` output. Drop the + ``ETag`` column and add a ``ProvisioningState`` column. +* ``az aimanager namespace list`` and ``az aimanager namespace show``: Improve ``-o table`` + output with ``Name``, ``ProvisioningState``, ``Age`` and ``Labels`` columns. +* ``az aimanager namespace modeldeployment list`` and ``show``: Improve ``-o table`` output + with ``Namespace``, ``Name``, ``ProvisioningState``, ``Replicas`` (current/desired), + ``Age``, ``ModelId`` (human-readable, resolved from the model) and ``Endpoint`` columns. + 1.5.2b2 +++++++ * ``az aimanager namespace modeldeployment``: Accept ``--ns`` as an alias of diff --git a/src/aimanager/azext_aimanager/_format.py b/src/aimanager/azext_aimanager/_format.py new file mode 100644 index 00000000000..f71000bce89 --- /dev/null +++ b/src/aimanager/azext_aimanager/_format.py @@ -0,0 +1,124 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from collections import OrderedDict + + +def _parse_resource_id(resource_id): + """Parse an ARM resource id into its component parts (best-effort).""" + if not resource_id: + return {} + from azure.mgmt.core.tools import parse_resource_id + return parse_resource_id(resource_id) + + +def aimanager_table_format(result): + """Format a single AI Manager resource for display with "-o table".""" + parsed = _parse_resource_id(result.get('id', '')) + properties = result.get('properties') or {} + return OrderedDict([ + ('Name', result.get('name', '')), + ('ProvisioningState', properties.get('provisioningState', '')), + ('ResourceGroup', parsed.get('resource_group', '')), + ('Location', result.get('location', '')), + ]) + + +def aimanager_list_table_format(results): + """Format a list of AI Manager resources for display with "-o table".""" + return [aimanager_table_format(r) for r in results] + + +def _labels_display(labels): + """Render a labels dict as comma-joined key=value pairs, sorted for stable output.""" + if not labels: + return '' + return ','.join('{}={}'.format(k, labels[k]) for k in sorted(labels)) + + +def _age_display(result): + """Render the resource age from systemData.createdAt, kubectl-style (e.g. 45d, 3h, 12m). + + Best-effort: returns '' when the timestamp is missing or cannot be parsed. + """ + created_at = (result.get('systemData') or {}).get('createdAt') + if not created_at: + return '' + try: + from datetime import datetime, timezone + from dateutil.parser import parse as parse_datetime + created = parse_datetime(created_at) + if created.tzinfo is None: + created = created.replace(tzinfo=timezone.utc) + delta = datetime.now(timezone.utc) - created + seconds = int(delta.total_seconds()) + if seconds < 0: + return '' + days, rem = divmod(seconds, 86400) + hours, rem = divmod(rem, 3600) + minutes, secs = divmod(rem, 60) + if days > 0: + return '{}d'.format(days) if hours == 0 else '{}d{}h'.format(days, hours) + if hours > 0: + return '{}h'.format(hours) if minutes == 0 else '{}h{}m'.format(hours, minutes) + if minutes > 0: + return '{}m'.format(minutes) + return '{}s'.format(secs) + except Exception: # pylint: disable=broad-except + return '' + + +def namespace_table_format(result): + """Format a single AI Manager namespace resource for display with "-o table".""" + properties = result.get('properties') or {} + return OrderedDict([ + ('Name', result.get('name', '')), + ('ProvisioningState', properties.get('provisioningState', '')), + ('Age', _age_display(result)), + ('Labels', _labels_display(properties.get('labels'))), + ]) + + +def namespace_list_table_format(results): + """Format a list of AI Manager namespace resources for display with "-o table".""" + return [namespace_table_format(r) for r in results] + + +def _replica_display(value): + """Render a replica count, using '-' when the count is not yet reported.""" + return str(value) if value is not None else '-' + + +def modeldeployment_table_format(result): + """Format a single model deployment resource for display with "-o table".""" + parsed = _parse_resource_id(result.get('id', '')) + properties = result.get('properties') or {} + status = properties.get('status') or {} + + # ``modelId`` (human-readable, e.g. "meta-llama/Llama-3-8B") is resolved from the + # deployment's ``modelResourceId`` by the custom list/show functions and injected onto + # the result. Shown blank when resolution is unavailable (the raw AIModel resource name + # is not human-readable, so it is intentionally not used as a fallback). + model_id = result.get('modelId') or '' + + replicas = '{}/{}'.format( + _replica_display(status.get('currentReplicas')), + _replica_display(status.get('desiredReplicas')), + ) + + return OrderedDict([ + ('Namespace', parsed.get('child_name_1', '')), + ('Name', result.get('name', '')), + ('ProvisioningState', properties.get('provisioningState', '')), + ('Replicas', replicas), + ('Age', _age_display(result)), + ('ModelId', model_id), + ('Endpoint', status.get('endpoint', '')), + ]) + + +def modeldeployment_list_table_format(results): + """Format a list of model deployment resources for display with "-o table".""" + return [modeldeployment_table_format(r) for r in results] diff --git a/src/aimanager/azext_aimanager/commands.py b/src/aimanager/azext_aimanager/commands.py index 896a59b5a31..adfb6b13260 100644 --- a/src/aimanager/azext_aimanager/commands.py +++ b/src/aimanager/azext_aimanager/commands.py @@ -9,6 +9,14 @@ AI_MODEL_TABLE_TRANSFORMER, CALCULATE_COST_TABLE_TRANSFORMER, ) +from azext_aimanager._format import ( + aimanager_table_format, + aimanager_list_table_format, + namespace_table_format, + namespace_list_table_format, + modeldeployment_table_format, + modeldeployment_list_table_format, +) from azext_aimanager._client_factory import ( cf_ai_managers, cf_ai_manager_namespaces, @@ -48,8 +56,8 @@ def load_command_table(self, _): with self.command_group("aimanager", ai_managers_sdk, client_factory=cf_ai_managers, is_preview=True) as g: g.custom_command("create", "create_aimanager", supports_no_wait=True) g.custom_command("update", "update_aimanager", supports_no_wait=True) - g.custom_show_command("show", "show_aimanager") - g.custom_command("list", "list_aimanager") + g.custom_show_command("show", "show_aimanager", table_transformer=aimanager_table_format) + g.custom_command("list", "list_aimanager", table_transformer=aimanager_list_table_format) g.custom_command("delete", "delete_aimanager", supports_no_wait=True, confirmation=True) g.custom_command("get-credentials", "aimanager_get_credentials") g.wait_command("wait") @@ -58,8 +66,8 @@ def load_command_table(self, _): with self.command_group("aimanager namespace", ai_manager_namespaces_sdk, client_factory=cf_ai_manager_namespaces, is_preview=True) as g: g.custom_command("add", "add_aimanager_namespace", supports_no_wait=True) g.custom_command("update", "update_aimanager_namespace", supports_no_wait=True) - g.custom_show_command("show", "show_aimanager_namespace") - g.custom_command("list", "list_aimanager_namespace") + g.custom_show_command("show", "show_aimanager_namespace", table_transformer=namespace_table_format) + g.custom_command("list", "list_aimanager_namespace", table_transformer=namespace_list_table_format) g.custom_command("delete", "delete_aimanager_namespace", supports_no_wait=True, confirmation=True) g.custom_command("get-credentials", "aimanager_namespace_get_credentials") g.custom_command("list-accesskeys", "aimanager_namespace_list_accesskeys") @@ -86,8 +94,8 @@ def load_command_table(self, _): client_factory=cf_model_deployments, is_preview=True) as g: g.custom_command("add", "add_modeldeployment", supports_no_wait=True) g.custom_command("update", "update_modeldeployment", supports_no_wait=True) - g.custom_show_command("show", "show_modeldeployment") - g.custom_command("list", "list_modeldeployment") + g.custom_show_command("show", "show_modeldeployment", table_transformer=modeldeployment_table_format) + g.custom_command("list", "list_modeldeployment", table_transformer=modeldeployment_list_table_format) g.custom_command("delete", "delete_modeldeployment", supports_no_wait=True, confirmation=True) g.custom_wait_command("wait", "show_modeldeployment") diff --git a/src/aimanager/azext_aimanager/custom.py b/src/aimanager/azext_aimanager/custom.py index b8d29271907..4025fb52061 100644 --- a/src/aimanager/azext_aimanager/custom.py +++ b/src/aimanager/azext_aimanager/custom.py @@ -597,14 +597,74 @@ def update_modeldeployment(cmd, client, resource_group_name, ai_manager_name, na def show_modeldeployment(cmd, client, resource_group_name, ai_manager_name, namespace_name, model_deployment_name): # pylint: disable=unused-argument - return client.get( + deployment = client.get( resource_group_name, ai_manager_name, namespace_name, model_deployment_name) + return _annotate_model_ids(cmd, [deployment])[0] def list_modeldeployment(cmd, client, resource_group_name, ai_manager_name, namespace_name): # pylint: disable=unused-argument - return client.list_by_ai_manager_namespace( + deployments = client.list_by_ai_manager_namespace( resource_group_name, ai_manager_name, namespace_name) + return _annotate_model_ids(cmd, list(deployments)) + + +def _annotate_model_ids(cmd, deployments): + """Resolve the human-readable model id (e.g. "meta-llama/Llama-3-8B") for each deployment + from its ``modelResourceId`` and return plain dicts with the id stashed under ``modelId`` + for table rendering. + + Plain dicts are returned (rather than the SDK model objects with an extra attribute) + because ``modelId`` is not a declared field on ``ModelDeployment``. azure-cli core 2.76+ + copies only declared fields when converting a model to output, which would silently drop + an injected attribute; a plain dict passes through untouched. + + The AIModel client is built once and lookups are memoized by ``(location, ai_model_name)`` + so a namespace with many deployments referencing the same model incurs a single GET per + distinct model rather than one per deployment. + + Best-effort: on any failure the affected deployment is returned unchanged (without a + ``modelId``) and the table shows a blank ModelId. + """ + from azure.mgmt.core.tools import parse_resource_id + from azure.cli.core.util import todict + from azext_aimanager._client_factory import cf_ai_models + + # Convert to plain (recursively nested) dicts first with todict, so an injected ``modelId`` + # survives CLI output conversion and nested camelCase keys (e.g. ``modelResourceId``, + # ``currentReplicas``) are preserved for the table formatter. + annotated = [todict(deployment) for deployment in deployments] + + ai_models_client = None + resolved = {} # (location, ai_model_name) -> modelId + + for deployment in annotated: + try: + properties = deployment.get('properties') or {} + model_resource_id = properties.get('modelResourceId') + if not model_resource_id: + continue + + parsed = parse_resource_id(model_resource_id) + location = parsed.get('name') # the location segment for an AIModel id + ai_model_name = parsed.get('resource_name') + if not location or not ai_model_name: + continue + + key = (location, ai_model_name) + if key not in resolved: + if ai_models_client is None: + ai_models_client = cf_ai_models(cmd.cli_ctx) + model = ai_models_client.get(location, ai_model_name) + resolved[key] = (todict(model).get('properties') or {}).get('modelId') + + model_id = resolved[key] + if model_id: + deployment['modelId'] = model_id + except Exception: # pylint: disable=broad-except + logger.debug("Failed to resolve human-readable modelId for a model deployment.", + exc_info=True) + return annotated def delete_modeldeployment(cmd, client, resource_group_name, ai_manager_name, namespace_name, diff --git a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py new file mode 100644 index 00000000000..e681bb19186 --- /dev/null +++ b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py @@ -0,0 +1,188 @@ +# -------------------------------------------------------------------------------------------- +# 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 azext_aimanager._format import ( + aimanager_table_format, + aimanager_list_table_format, + namespace_table_format, + namespace_list_table_format, + modeldeployment_table_format, + modeldeployment_list_table_format, +) + + +class TestAIManagerTableFormat(unittest.TestCase): + """Test cases for AI Manager table output formatting.""" + + def _sample(self): + return { + "id": ( + "/subscriptions/26fe00f8-0000-0000-0000-bb1d2e00343a" + "/resourceGroups/yiralirg" + "/providers/Microsoft.ContainerService/aiManagers/aimbyo" + ), + "name": "aimbyo", + "location": "westus2", + "properties": {"provisioningState": "Succeeded"}, + } + + def test_table_format_columns(self): + result = aimanager_table_format(self._sample()) + self.assertEqual( + list(result.keys()), + ["Name", "ProvisioningState", "ResourceGroup", "Location"], + ) + + def test_table_format_values(self): + result = aimanager_table_format(self._sample()) + self.assertEqual(result["Name"], "aimbyo") + self.assertEqual(result["ResourceGroup"], "yiralirg") + self.assertEqual(result["Location"], "westus2") + self.assertEqual(result["ProvisioningState"], "Succeeded") + + def test_table_format_missing_fields(self): + result = aimanager_table_format({}) + self.assertEqual(result["Name"], "") + self.assertEqual(result["ResourceGroup"], "") + self.assertEqual(result["ProvisioningState"], "") + + def test_table_format_null_properties(self): + # 'properties' present but null (Optional in the vendored model) must not raise. + result = aimanager_table_format({"name": "aimbyo", "properties": None}) + self.assertEqual(result["Name"], "aimbyo") + self.assertEqual(result["ProvisioningState"], "") + + def test_list_table_format(self): + results = aimanager_list_table_format([self._sample(), self._sample()]) + self.assertEqual(len(results), 2) + self.assertEqual(results[0]["Name"], "aimbyo") + + +class TestNamespaceTableFormat(unittest.TestCase): + """Test cases for AI Manager namespace table output formatting.""" + + def _sample(self): + return { + "id": ( + "/subscriptions/26fe00f8-0000-0000-0000-bb1d2e00343a" + "/resourceGroups/yiralirg" + "/providers/Microsoft.ContainerService/aiManagers/aimbyo" + "/namespaces/ns1" + ), + "name": "ns1", + "systemData": {"createdAt": "2020-01-01T00:00:00+00:00"}, + "properties": { + "provisioningState": "Succeeded", + "labels": {"team": "payments", "env": "prod"}, + }, + } + + def test_table_format_columns(self): + result = namespace_table_format(self._sample()) + self.assertEqual( + list(result.keys()), + ["Name", "ProvisioningState", "Age", "Labels"], + ) + + def test_table_format_values(self): + result = namespace_table_format(self._sample()) + self.assertEqual(result["Name"], "ns1") + self.assertEqual(result["ProvisioningState"], "Succeeded") + self.assertEqual(result["Labels"], "env=prod,team=payments") + # Age is derived from a fixed 2020 timestamp, so it should be reported in days. + self.assertIn("d", result["Age"]) + + def test_table_format_missing_fields(self): + result = namespace_table_format({}) + self.assertEqual(result["Name"], "") + self.assertEqual(result["ProvisioningState"], "") + self.assertEqual(result["Age"], "") + self.assertEqual(result["Labels"], "") + + def test_table_format_null_properties(self): + result = namespace_table_format({"name": "ns1", "properties": None}) + self.assertEqual(result["Name"], "ns1") + self.assertEqual(result["ProvisioningState"], "") + self.assertEqual(result["Age"], "") + self.assertEqual(result["Labels"], "") + + def test_list_table_format(self): + results = namespace_list_table_format([self._sample(), self._sample()]) + self.assertEqual(len(results), 2) + self.assertEqual(results[0]["Name"], "ns1") + + +class TestModelDeploymentTableFormat(unittest.TestCase): + """Test cases for model deployment table output formatting.""" + + def _sample(self): + return { + "id": ( + "/subscriptions/26fe00f8-0000-0000-0000-bb1d2e00343a" + "/resourceGroups/yiralirg" + "/providers/Microsoft.ContainerService/aiManagers/aimbyo" + "/namespaces/ns1/modelDeployments/md1" + ), + "name": "md1", + "modelId": "meta-llama/Llama-3-8B", + "systemData": {"createdAt": "2020-01-01T00:00:00+00:00"}, + "properties": { + "provisioningState": "Succeeded", + "modelResourceId": ( + "/subscriptions/26fe00f8-0000-0000-0000-bb1d2e00343a" + "/providers/Microsoft.ContainerService/locations/westus2" + "/aiModels/llama3" + ), + "status": { + "endpoint": "https://md1.example.com", + "currentReplicas": 1, + "desiredReplicas": 3, + }, + }, + } + + def test_table_format_columns(self): + result = modeldeployment_table_format(self._sample()) + self.assertEqual( + list(result.keys()), + ["Namespace", "Name", "ProvisioningState", "Replicas", + "Age", "ModelId", "Endpoint"], + ) + + def test_table_format_values(self): + result = modeldeployment_table_format(self._sample()) + self.assertEqual(result["Namespace"], "ns1") + self.assertEqual(result["Name"], "md1") + self.assertEqual(result["ProvisioningState"], "Succeeded") + self.assertEqual(result["Replicas"], "1/3") + self.assertIn("d", result["Age"]) + self.assertEqual(result["ModelId"], "meta-llama/Llama-3-8B") + self.assertEqual(result["Endpoint"], "https://md1.example.com") + + def test_model_id_blank_when_unresolved(self): + # When the human-readable modelId was not injected, the column is blank rather than + # falling back to the (unreadable) AIModel resource name. + sample = self._sample() + del sample["modelId"] + result = modeldeployment_table_format(sample) + self.assertEqual(result["ModelId"], "") + + def test_replicas_missing_status(self): + result = modeldeployment_table_format({"name": "md1", "properties": None}) + self.assertEqual(result["Replicas"], "-/-") + self.assertEqual(result["Endpoint"], "") + self.assertEqual(result["ModelId"], "") + self.assertEqual(result["Age"], "") + + def test_list_table_format(self): + results = modeldeployment_list_table_format([self._sample(), self._sample()]) + self.assertEqual(len(results), 2) + self.assertEqual(results[0]["ModelId"], "meta-llama/Llama-3-8B") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/aimanager/azext_aimanager/tests/latest/test_modeldeployment.py b/src/aimanager/azext_aimanager/tests/latest/test_modeldeployment.py index 119ccad7800..d681d8b66c9 100644 --- a/src/aimanager/azext_aimanager/tests/latest/test_modeldeployment.py +++ b/src/aimanager/azext_aimanager/tests/latest/test_modeldeployment.py @@ -126,6 +126,78 @@ def test_update_preserves_omitted_properties_and_uses_etag( match_condition=MatchConditions.IfNotModified, ) + @patch("azext_aimanager._client_factory.cf_ai_models") + def test_annotate_model_ids_returns_plain_dicts_with_model_id(self, cf_ai_models): + # The AIModel GET resolves the human-readable modelId. + model = models.AIModel({"properties": {"modelId": "meta-llama/Llama-3-8B"}}) + cf_ai_models.return_value.get.return_value = model + + deployment = models.ModelDeployment({ + "name": "md1", + "properties": { + "modelResourceId": ( + "/subscriptions/s/providers/Microsoft.ContainerService" + "/locations/westus2/aiModels/llama3" + ), + }, + }) + cmd = SimpleNamespace(cli_ctx=object()) + + results = custom._annotate_model_ids(cmd, [deployment]) + + # Must be a plain dict (not the SDK model) so the injected modelId — which is not a + # declared ModelDeployment field — survives azure-cli 2.76+ output conversion. + self.assertEqual(len(results), 1) + self.assertIsInstance(results[0], dict) + self.assertEqual(results[0]["modelId"], "meta-llama/Llama-3-8B") + # A single distinct model is fetched once. + cf_ai_models.return_value.get.assert_called_once_with("westus2", "llama3") + + @patch("azext_aimanager._client_factory.cf_ai_models") + def test_annotate_model_ids_memoizes_repeated_models(self, cf_ai_models): + model = models.AIModel({"properties": {"modelId": "meta-llama/Llama-3-8B"}}) + cf_ai_models.return_value.get.return_value = model + + def make(name): + return models.ModelDeployment({ + "name": name, + "properties": { + "modelResourceId": ( + "/subscriptions/s/providers/Microsoft.ContainerService" + "/locations/westus2/aiModels/llama3" + ), + }, + }) + + cmd = SimpleNamespace(cli_ctx=object()) + results = custom._annotate_model_ids(cmd, [make("md1"), make("md2")]) + + self.assertEqual([r["modelId"] for r in results], + ["meta-llama/Llama-3-8B", "meta-llama/Llama-3-8B"]) + # Two deployments, same model -> one GET. + cf_ai_models.return_value.get.assert_called_once() + + @patch("azext_aimanager._client_factory.cf_ai_models") + def test_annotate_model_ids_blank_on_resolution_failure(self, cf_ai_models): + cf_ai_models.return_value.get.side_effect = Exception("not found") + + deployment = models.ModelDeployment({ + "name": "md1", + "properties": { + "modelResourceId": ( + "/subscriptions/s/providers/Microsoft.ContainerService" + "/locations/westus2/aiModels/llama3" + ), + }, + }) + cmd = SimpleNamespace(cli_ctx=object()) + + results = custom._annotate_model_ids(cmd, [deployment]) + + # No modelId injected; formatter will render a blank column. + self.assertIsInstance(results[0], dict) + self.assertNotIn("modelId", results[0]) + if __name__ == '__main__': unittest.main() diff --git a/src/aimanager/setup.py b/src/aimanager/setup.py index 5683a85674b..2455cd1c0e6 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.5.2b2' +VERSION = '1.5.3b1' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers