From cd1292a70018adbaa4c8fe9f7553592ba4c05246 Mon Sep 17 00:00:00 2001 From: circy9 Date: Wed, 9 Sep 2026 17:08:50 -0700 Subject: [PATCH 01/15] {aimanager} Improve `az aimanager list/show` table output Drop the `ETag` column and add `ProvisioningState` and `Subscription` columns to the `-o table` output for `az aimanager list` and `az aimanager show`, matching the information shown by peer commands such as `az aks list`. Fixes AB#39624822 Co-Authored-By: Claude --- src/aimanager/HISTORY.rst | 5 ++ src/aimanager/azext_aimanager/_format.py | 31 ++++++++++ src/aimanager/azext_aimanager/commands.py | 8 ++- .../tests/latest/test_aimanager_format.py | 60 +++++++++++++++++++ src/aimanager/setup.py | 2 +- 5 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 src/aimanager/azext_aimanager/_format.py create mode 100644 src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py diff --git a/src/aimanager/HISTORY.rst b/src/aimanager/HISTORY.rst index a835b55e847..13a02bbe3d5 100644 --- a/src/aimanager/HISTORY.rst +++ b/src/aimanager/HISTORY.rst @@ -3,6 +3,11 @@ Release History =============== +1.5.3b1 +++++++ +* ``az aimanager list`` and ``az aimanager show``: Improve ``-o table`` output. Drop the + ``ETag`` column and add ``ProvisioningState`` and ``Subscription`` columns. + 1.5.2b1 ++++++ * Refactor validation code to make the name validators consistent diff --git a/src/aimanager/azext_aimanager/_format.py b/src/aimanager/azext_aimanager/_format.py new file mode 100644 index 00000000000..3993b11fd12 --- /dev/null +++ b/src/aimanager/azext_aimanager/_format.py @@ -0,0 +1,31 @@ +# -------------------------------------------------------------------------------------------- +# 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', '')) + return OrderedDict([ + ('Name', result.get('name', '')), + ('ResourceGroup', parsed.get('resource_group', '')), + ('Location', result.get('location', '')), + ('ProvisioningState', result.get('properties', {}).get('provisioningState', '')), + ('Subscription', parsed.get('subscription', '')), + ]) + + +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] diff --git a/src/aimanager/azext_aimanager/commands.py b/src/aimanager/azext_aimanager/commands.py index 896a59b5a31..a2f30e083fc 100644 --- a/src/aimanager/azext_aimanager/commands.py +++ b/src/aimanager/azext_aimanager/commands.py @@ -9,6 +9,10 @@ AI_MODEL_TABLE_TRANSFORMER, CALCULATE_COST_TABLE_TRANSFORMER, ) +from azext_aimanager._format import ( + aimanager_table_format, + aimanager_list_table_format, +) from azext_aimanager._client_factory import ( cf_ai_managers, cf_ai_manager_namespaces, @@ -48,8 +52,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") 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..c0104946714 --- /dev/null +++ b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py @@ -0,0 +1,60 @@ +# -------------------------------------------------------------------------------------------- +# 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, +) + + +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", + "eTag": "b918e441-390c-4b01-a922-dea9b42a03df", + "properties": {"provisioningState": "Succeeded"}, + } + + def test_table_format_columns(self): + result = aimanager_table_format(self._sample()) + self.assertEqual( + list(result.keys()), + ["Name", "ResourceGroup", "Location", "ProvisioningState", "Subscription"], + ) + self.assertNotIn("ETag", result) + + 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") + self.assertEqual(result["Subscription"], "26fe00f8-0000-0000-0000-bb1d2e00343a") + + def test_table_format_missing_fields(self): + result = aimanager_table_format({}) + self.assertEqual(result["Name"], "") + self.assertEqual(result["ResourceGroup"], "") + self.assertEqual(result["Subscription"], "") + 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") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/aimanager/setup.py b/src/aimanager/setup.py index b1756f0ba02..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.2b1' +VERSION = '1.5.3b1' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers From 6d9c41a41ee728297bdf3acfa472b8575b4d6d7d Mon Sep 17 00:00:00 2001 From: circy9 Date: Thu, 10 Sep 2026 09:59:50 -0700 Subject: [PATCH 02/15] {aimanager} Reorder table columns Order as Name, ProvisioningState, ResourceGroup, Subscription, Location. Co-Authored-By: Claude --- src/aimanager/azext_aimanager/_format.py | 4 ++-- .../azext_aimanager/tests/latest/test_aimanager_format.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/aimanager/azext_aimanager/_format.py b/src/aimanager/azext_aimanager/_format.py index 3993b11fd12..e23be201471 100644 --- a/src/aimanager/azext_aimanager/_format.py +++ b/src/aimanager/azext_aimanager/_format.py @@ -19,10 +19,10 @@ def aimanager_table_format(result): parsed = _parse_resource_id(result.get('id', '')) return OrderedDict([ ('Name', result.get('name', '')), - ('ResourceGroup', parsed.get('resource_group', '')), - ('Location', result.get('location', '')), ('ProvisioningState', result.get('properties', {}).get('provisioningState', '')), + ('ResourceGroup', parsed.get('resource_group', '')), ('Subscription', parsed.get('subscription', '')), + ('Location', result.get('location', '')), ]) diff --git a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py index c0104946714..d9bb5764073 100644 --- a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py +++ b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py @@ -31,7 +31,7 @@ def test_table_format_columns(self): result = aimanager_table_format(self._sample()) self.assertEqual( list(result.keys()), - ["Name", "ResourceGroup", "Location", "ProvisioningState", "Subscription"], + ["Name", "ProvisioningState", "ResourceGroup", "Subscription", "Location"], ) self.assertNotIn("ETag", result) From 60cfbb511829299df0ad8e21ca496b7ee7dedeee Mon Sep 17 00:00:00 2001 From: circy9 Date: Thu, 10 Sep 2026 10:01:36 -0700 Subject: [PATCH 03/15] {aimanager} Handle null properties in table formatter Guard against `properties` being present but null (Optional in the vendored model) so `-o table` does not raise AttributeError. Add a regression test for the null-properties case. Co-Authored-By: Claude --- src/aimanager/azext_aimanager/_format.py | 3 ++- .../azext_aimanager/tests/latest/test_aimanager_format.py | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/aimanager/azext_aimanager/_format.py b/src/aimanager/azext_aimanager/_format.py index e23be201471..79fbbef3fe5 100644 --- a/src/aimanager/azext_aimanager/_format.py +++ b/src/aimanager/azext_aimanager/_format.py @@ -17,9 +17,10 @@ def _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', result.get('properties', {}).get('provisioningState', '')), + ('ProvisioningState', properties.get('provisioningState', '')), ('ResourceGroup', parsed.get('resource_group', '')), ('Subscription', parsed.get('subscription', '')), ('Location', result.get('location', '')), diff --git a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py index d9bb5764073..7ac12e78a7c 100644 --- a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py +++ b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py @@ -50,6 +50,12 @@ def test_table_format_missing_fields(self): self.assertEqual(result["Subscription"], "") 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) From 2bf36a6c4bd18ff5db09e168754bbe7883ec625f Mon Sep 17 00:00:00 2001 From: circy9 Date: Thu, 10 Sep 2026 10:07:04 -0700 Subject: [PATCH 04/15] {aimanager} Drop Subscription column from table output Subscription is constant within a single list call, so remove it. Table columns are now Name, ProvisioningState, ResourceGroup, Location. Co-Authored-By: Claude --- src/aimanager/HISTORY.rst | 2 +- src/aimanager/azext_aimanager/_format.py | 1 - .../azext_aimanager/tests/latest/test_aimanager_format.py | 4 +--- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/aimanager/HISTORY.rst b/src/aimanager/HISTORY.rst index 13a02bbe3d5..b37d68f469a 100644 --- a/src/aimanager/HISTORY.rst +++ b/src/aimanager/HISTORY.rst @@ -6,7 +6,7 @@ Release History 1.5.3b1 ++++++ * ``az aimanager list`` and ``az aimanager show``: Improve ``-o table`` output. Drop the - ``ETag`` column and add ``ProvisioningState`` and ``Subscription`` columns. + ``ETag`` column and add a ``ProvisioningState`` column. 1.5.2b1 ++++++ diff --git a/src/aimanager/azext_aimanager/_format.py b/src/aimanager/azext_aimanager/_format.py index 79fbbef3fe5..506d5f64cd6 100644 --- a/src/aimanager/azext_aimanager/_format.py +++ b/src/aimanager/azext_aimanager/_format.py @@ -22,7 +22,6 @@ def aimanager_table_format(result): ('Name', result.get('name', '')), ('ProvisioningState', properties.get('provisioningState', '')), ('ResourceGroup', parsed.get('resource_group', '')), - ('Subscription', parsed.get('subscription', '')), ('Location', result.get('location', '')), ]) diff --git a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py index 7ac12e78a7c..b65fba2509a 100644 --- a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py +++ b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py @@ -31,7 +31,7 @@ def test_table_format_columns(self): result = aimanager_table_format(self._sample()) self.assertEqual( list(result.keys()), - ["Name", "ProvisioningState", "ResourceGroup", "Subscription", "Location"], + ["Name", "ProvisioningState", "ResourceGroup", "Location"], ) self.assertNotIn("ETag", result) @@ -41,13 +41,11 @@ def test_table_format_values(self): self.assertEqual(result["ResourceGroup"], "yiralirg") self.assertEqual(result["Location"], "westus2") self.assertEqual(result["ProvisioningState"], "Succeeded") - self.assertEqual(result["Subscription"], "26fe00f8-0000-0000-0000-bb1d2e00343a") def test_table_format_missing_fields(self): result = aimanager_table_format({}) self.assertEqual(result["Name"], "") self.assertEqual(result["ResourceGroup"], "") - self.assertEqual(result["Subscription"], "") self.assertEqual(result["ProvisioningState"], "") def test_table_format_null_properties(self): From 4f5a4d353fff1011fa8e970adf14b23e835b11df Mon Sep 17 00:00:00 2001 From: circy9 Date: Thu, 10 Sep 2026 10:13:36 -0700 Subject: [PATCH 05/15] {aimanager} Add table formatter for namespace list/show Format `az aimanager namespace list` and `show` `-o table` output with Name, ProvisioningState, AIManager (parent, parsed from id) and ResourceGroup columns. Add unit tests. Co-Authored-By: Claude --- src/aimanager/HISTORY.rst | 2 + src/aimanager/azext_aimanager/_format.py | 17 +++++++ src/aimanager/azext_aimanager/commands.py | 6 ++- .../tests/latest/test_aimanager_format.py | 50 +++++++++++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/aimanager/HISTORY.rst b/src/aimanager/HISTORY.rst index b37d68f469a..0a2d94ccf44 100644 --- a/src/aimanager/HISTORY.rst +++ b/src/aimanager/HISTORY.rst @@ -7,6 +7,8 @@ Release History ++++++ * ``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``, ``AIManager`` and ``ResourceGroup`` columns. 1.5.2b1 ++++++ diff --git a/src/aimanager/azext_aimanager/_format.py b/src/aimanager/azext_aimanager/_format.py index 506d5f64cd6..12c3026673e 100644 --- a/src/aimanager/azext_aimanager/_format.py +++ b/src/aimanager/azext_aimanager/_format.py @@ -29,3 +29,20 @@ def aimanager_table_format(result): 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 namespace_table_format(result): + """Format a single AI Manager namespace 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', '')), + ('AIManager', parsed.get('name', '')), + ('ResourceGroup', parsed.get('resource_group', '')), + ]) + + +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] diff --git a/src/aimanager/azext_aimanager/commands.py b/src/aimanager/azext_aimanager/commands.py index a2f30e083fc..3cc7b117df0 100644 --- a/src/aimanager/azext_aimanager/commands.py +++ b/src/aimanager/azext_aimanager/commands.py @@ -12,6 +12,8 @@ from azext_aimanager._format import ( aimanager_table_format, aimanager_list_table_format, + namespace_table_format, + namespace_list_table_format, ) from azext_aimanager._client_factory import ( cf_ai_managers, @@ -62,8 +64,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") diff --git a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py index b65fba2509a..b85cf9587ff 100644 --- a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py +++ b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py @@ -8,6 +8,8 @@ from azext_aimanager._format import ( aimanager_table_format, aimanager_list_table_format, + namespace_table_format, + namespace_list_table_format, ) @@ -60,5 +62,53 @@ def test_list_table_format(self): 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", + "properties": {"provisioningState": "Succeeded"}, + } + + def test_table_format_columns(self): + result = namespace_table_format(self._sample()) + self.assertEqual( + list(result.keys()), + ["Name", "ProvisioningState", "AIManager", "ResourceGroup"], + ) + self.assertNotIn("ETag", result) + + 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["AIManager"], "aimbyo") + self.assertEqual(result["ResourceGroup"], "yiralirg") + + def test_table_format_missing_fields(self): + result = namespace_table_format({}) + self.assertEqual(result["Name"], "") + self.assertEqual(result["ProvisioningState"], "") + self.assertEqual(result["AIManager"], "") + self.assertEqual(result["ResourceGroup"], "") + + def test_table_format_null_properties(self): + result = namespace_table_format({"name": "ns1", "properties": None}) + self.assertEqual(result["Name"], "ns1") + self.assertEqual(result["ProvisioningState"], "") + + def test_list_table_format(self): + results = namespace_list_table_format([self._sample(), self._sample()]) + self.assertEqual(len(results), 2) + self.assertEqual(results[0]["AIManager"], "aimbyo") + + if __name__ == "__main__": unittest.main() From b4e5d7f1b217421696df891892caa310e4090485 Mon Sep 17 00:00:00 2001 From: circy9 Date: Thu, 10 Sep 2026 10:24:20 -0700 Subject: [PATCH 06/15] {aimanager} Add table formatter for modeldeployment list/show Format `az aimanager namespace modeldeployment list` and `show` `-o table` output with Name, ProvisioningState, ModelId, Replicas (current/desired), Endpoint, Namespace, AIManager and ResourceGroup. ModelId is the human-readable model identifier: the custom list/show functions best-effort resolve the deployment's modelResourceId to the AIModel's properties.modelId; the formatter falls back to the AIModel resource name when resolution is unavailable. Co-Authored-By: Claude --- src/aimanager/HISTORY.rst | 4 ++ src/aimanager/azext_aimanager/_format.py | 41 ++++++++++++ src/aimanager/azext_aimanager/commands.py | 6 +- src/aimanager/azext_aimanager/custom.py | 38 ++++++++++- .../tests/latest/test_aimanager_format.py | 67 +++++++++++++++++++ 5 files changed, 152 insertions(+), 4 deletions(-) diff --git a/src/aimanager/HISTORY.rst b/src/aimanager/HISTORY.rst index 0a2d94ccf44..8f7c54fd6c9 100644 --- a/src/aimanager/HISTORY.rst +++ b/src/aimanager/HISTORY.rst @@ -9,6 +9,10 @@ Release History ``ETag`` column and add a ``ProvisioningState`` column. * ``az aimanager namespace list`` and ``az aimanager namespace show``: Improve ``-o table`` output with ``Name``, ``ProvisioningState``, ``AIManager`` and ``ResourceGroup`` columns. +* ``az aimanager namespace modeldeployment list`` and ``show``: Improve ``-o table`` output + with ``Name``, ``ProvisioningState``, ``ModelId`` (human-readable, resolved from the model), + ``Replicas`` (current/desired), ``Endpoint``, ``Namespace``, ``AIManager`` and + ``ResourceGroup`` columns. 1.5.2b1 ++++++ diff --git a/src/aimanager/azext_aimanager/_format.py b/src/aimanager/azext_aimanager/_format.py index 12c3026673e..df87ae267f5 100644 --- a/src/aimanager/azext_aimanager/_format.py +++ b/src/aimanager/azext_aimanager/_format.py @@ -46,3 +46,44 @@ def namespace_table_format(result): 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. Fall back to the AIModel resource name when resolution is unavailable. + model_id = result.get('modelId') + if not model_id: + model_ref = _parse_resource_id(properties.get('modelResourceId', '')) + model_id = model_ref.get('resource_name', '') + + replicas = '{}/{}'.format( + _replica_display(status.get('currentReplicas')), + _replica_display(status.get('desiredReplicas')), + ) + + return OrderedDict([ + ('Name', result.get('name', '')), + ('ProvisioningState', properties.get('provisioningState', '')), + ('ModelId', model_id or ''), + ('Replicas', replicas), + ('Endpoint', status.get('endpoint', '')), + ('Namespace', parsed.get('child_name_1', '')), + ('AIManager', parsed.get('name', '')), + ('ResourceGroup', parsed.get('resource_group', '')), + ]) + + +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 3cc7b117df0..adfb6b13260 100644 --- a/src/aimanager/azext_aimanager/commands.py +++ b/src/aimanager/azext_aimanager/commands.py @@ -14,6 +14,8 @@ 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, @@ -92,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..43670c87e2d 100644 --- a/src/aimanager/azext_aimanager/custom.py +++ b/src/aimanager/azext_aimanager/custom.py @@ -597,14 +597,48 @@ 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) + _annotate_model_id(cmd, deployment) + return deployment 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_id(cmd, d) for d in deployments] + + +def _annotate_model_id(cmd, deployment): + """Resolve the human-readable model id (e.g. "meta-llama/Llama-3-8B") from a deployment's + ``modelResourceId`` and stash it on the deployment as ``modelId`` for table rendering. + + Best-effort: on any failure the deployment is returned unchanged and table output falls + back to the AIModel resource name parsed from the id. + """ + try: + properties = deployment.get('properties') or {} + model_resource_id = properties.get('modelResourceId') + if not model_resource_id: + return deployment + + from azure.mgmt.core.tools import parse_resource_id + 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: + return deployment + + from azext_aimanager._client_factory import cf_ai_models + model = cf_ai_models(cmd.cli_ctx).get(location, ai_model_name) + model_id = (model.get('properties') or {}).get('modelId') + 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 deployment 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 index b85cf9587ff..2746fbbb86a 100644 --- a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py +++ b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py @@ -10,6 +10,8 @@ aimanager_list_table_format, namespace_table_format, namespace_list_table_format, + modeldeployment_table_format, + modeldeployment_list_table_format, ) @@ -110,5 +112,70 @@ def test_list_table_format(self): self.assertEqual(results[0]["AIManager"], "aimbyo") +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", + "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()), + ["Name", "ProvisioningState", "ModelId", "Replicas", + "Endpoint", "Namespace", "AIManager", "ResourceGroup"], + ) + + def test_table_format_values(self): + result = modeldeployment_table_format(self._sample()) + self.assertEqual(result["Name"], "md1") + self.assertEqual(result["ProvisioningState"], "Succeeded") + self.assertEqual(result["ModelId"], "meta-llama/Llama-3-8B") + self.assertEqual(result["Replicas"], "1/3") + self.assertEqual(result["Endpoint"], "https://md1.example.com") + self.assertEqual(result["Namespace"], "ns1") + self.assertEqual(result["AIManager"], "aimbyo") + self.assertEqual(result["ResourceGroup"], "yiralirg") + + def test_model_id_fallback_to_resource_name(self): + sample = self._sample() + del sample["modelId"] + result = modeldeployment_table_format(sample) + self.assertEqual(result["ModelId"], "llama3") + + 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"], "") + + 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() From dfad17f339faa86e8f8a907af86e9f287c5c51e0 Mon Sep 17 00:00:00 2001 From: circy9 Date: Thu, 10 Sep 2026 10:28:43 -0700 Subject: [PATCH 07/15] {aimanager} Memoize AIModel lookups when resolving modelId Build the AIModel client once per list/show and cache lookups by (location, ai_model_name), so a namespace with many deployments that reference the same model incurs one GET per distinct model instead of one per deployment. Co-Authored-By: Claude --- src/aimanager/azext_aimanager/custom.py | 79 +++++++++++++++---------- 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/src/aimanager/azext_aimanager/custom.py b/src/aimanager/azext_aimanager/custom.py index 43670c87e2d..1da911381bd 100644 --- a/src/aimanager/azext_aimanager/custom.py +++ b/src/aimanager/azext_aimanager/custom.py @@ -599,46 +599,63 @@ def show_modeldeployment(cmd, client, resource_group_name, ai_manager_name, name model_deployment_name): # pylint: disable=unused-argument deployment = client.get( resource_group_name, ai_manager_name, namespace_name, model_deployment_name) - _annotate_model_id(cmd, deployment) + _annotate_model_ids(cmd, [deployment]) return deployment def list_modeldeployment(cmd, client, resource_group_name, ai_manager_name, namespace_name): # pylint: disable=unused-argument - deployments = client.list_by_ai_manager_namespace( - resource_group_name, ai_manager_name, namespace_name) - return [_annotate_model_id(cmd, d) for d in deployments] + deployments = list(client.list_by_ai_manager_namespace( + resource_group_name, ai_manager_name, namespace_name)) + _annotate_model_ids(cmd, deployments) + return deployments -def _annotate_model_id(cmd, deployment): - """Resolve the human-readable model id (e.g. "meta-llama/Llama-3-8B") from a deployment's - ``modelResourceId`` and stash it on the deployment as ``modelId`` for table rendering. +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 stash it on the deployment as ``modelId`` for table + rendering. - Best-effort: on any failure the deployment is returned unchanged and table output falls - back to the AIModel resource name parsed from the id. + 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 left unchanged and table output + falls back to the AIModel resource name parsed from the id. """ - try: - properties = deployment.get('properties') or {} - model_resource_id = properties.get('modelResourceId') - if not model_resource_id: - return deployment - - from azure.mgmt.core.tools import parse_resource_id - 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: - return deployment - - from azext_aimanager._client_factory import cf_ai_models - model = cf_ai_models(cmd.cli_ctx).get(location, ai_model_name) - model_id = (model.get('properties') or {}).get('modelId') - 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 deployment + from azure.mgmt.core.tools import parse_resource_id + from azext_aimanager._client_factory import cf_ai_models + + ai_models_client = None + resolved = {} # (location, ai_model_name) -> modelId + + for deployment in deployments: + 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] = (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 deployments def delete_modeldeployment(cmd, client, resource_group_name, ai_manager_name, namespace_name, From b1452dfb4dcf07b2a87ece1990dadc9aba9334c7 Mon Sep 17 00:00:00 2001 From: circy9 Date: Thu, 10 Sep 2026 10:31:38 -0700 Subject: [PATCH 08/15] {aimanager} Trim namespace and modeldeployment table columns Namespace table: keep only Name and ProvisioningState. Modeldeployment table: drop AIManager and ResourceGroup, keeping Name, ProvisioningState, ModelId, Replicas, Endpoint and Namespace. Co-Authored-By: Claude --- src/aimanager/HISTORY.rst | 5 ++--- src/aimanager/azext_aimanager/_format.py | 5 ----- .../tests/latest/test_aimanager_format.py | 12 +++--------- 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/src/aimanager/HISTORY.rst b/src/aimanager/HISTORY.rst index 8f7c54fd6c9..42b78cce6b9 100644 --- a/src/aimanager/HISTORY.rst +++ b/src/aimanager/HISTORY.rst @@ -8,11 +8,10 @@ Release History * ``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``, ``AIManager`` and ``ResourceGroup`` columns. + output with ``Name`` and ``ProvisioningState`` columns. * ``az aimanager namespace modeldeployment list`` and ``show``: Improve ``-o table`` output with ``Name``, ``ProvisioningState``, ``ModelId`` (human-readable, resolved from the model), - ``Replicas`` (current/desired), ``Endpoint``, ``Namespace``, ``AIManager`` and - ``ResourceGroup`` columns. + ``Replicas`` (current/desired), ``Endpoint`` and ``Namespace`` columns. 1.5.2b1 ++++++ diff --git a/src/aimanager/azext_aimanager/_format.py b/src/aimanager/azext_aimanager/_format.py index df87ae267f5..dc102ca68ca 100644 --- a/src/aimanager/azext_aimanager/_format.py +++ b/src/aimanager/azext_aimanager/_format.py @@ -33,13 +33,10 @@ def aimanager_list_table_format(results): def namespace_table_format(result): """Format a single AI Manager namespace 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', '')), - ('AIManager', parsed.get('name', '')), - ('ResourceGroup', parsed.get('resource_group', '')), ]) @@ -79,8 +76,6 @@ def modeldeployment_table_format(result): ('Replicas', replicas), ('Endpoint', status.get('endpoint', '')), ('Namespace', parsed.get('child_name_1', '')), - ('AIManager', parsed.get('name', '')), - ('ResourceGroup', parsed.get('resource_group', '')), ]) diff --git a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py index 2746fbbb86a..21fd36eebcb 100644 --- a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py +++ b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py @@ -83,7 +83,7 @@ def test_table_format_columns(self): result = namespace_table_format(self._sample()) self.assertEqual( list(result.keys()), - ["Name", "ProvisioningState", "AIManager", "ResourceGroup"], + ["Name", "ProvisioningState"], ) self.assertNotIn("ETag", result) @@ -91,15 +91,11 @@ 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["AIManager"], "aimbyo") - self.assertEqual(result["ResourceGroup"], "yiralirg") def test_table_format_missing_fields(self): result = namespace_table_format({}) self.assertEqual(result["Name"], "") self.assertEqual(result["ProvisioningState"], "") - self.assertEqual(result["AIManager"], "") - self.assertEqual(result["ResourceGroup"], "") def test_table_format_null_properties(self): result = namespace_table_format({"name": "ns1", "properties": None}) @@ -109,7 +105,7 @@ def test_table_format_null_properties(self): def test_list_table_format(self): results = namespace_list_table_format([self._sample(), self._sample()]) self.assertEqual(len(results), 2) - self.assertEqual(results[0]["AIManager"], "aimbyo") + self.assertEqual(results[0]["Name"], "ns1") class TestModelDeploymentTableFormat(unittest.TestCase): @@ -145,7 +141,7 @@ def test_table_format_columns(self): self.assertEqual( list(result.keys()), ["Name", "ProvisioningState", "ModelId", "Replicas", - "Endpoint", "Namespace", "AIManager", "ResourceGroup"], + "Endpoint", "Namespace"], ) def test_table_format_values(self): @@ -156,8 +152,6 @@ def test_table_format_values(self): self.assertEqual(result["Replicas"], "1/3") self.assertEqual(result["Endpoint"], "https://md1.example.com") self.assertEqual(result["Namespace"], "ns1") - self.assertEqual(result["AIManager"], "aimbyo") - self.assertEqual(result["ResourceGroup"], "yiralirg") def test_model_id_fallback_to_resource_name(self): sample = self._sample() From c787e126973cdcdc10033175a4890d3a58d6a092 Mon Sep 17 00:00:00 2001 From: circy9 Date: Thu, 10 Sep 2026 10:38:40 -0700 Subject: [PATCH 09/15] {aimanager} Add Labels column to namespace table Render the Kubernetes namespace labels (properties.labels) as comma-joined key=value pairs, sorted for stable output, similar to kubectl get ns --show-labels. Co-Authored-By: Claude --- src/aimanager/HISTORY.rst | 2 +- src/aimanager/azext_aimanager/_format.py | 8 ++++++++ .../tests/latest/test_aimanager_format.py | 10 ++++++++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/aimanager/HISTORY.rst b/src/aimanager/HISTORY.rst index 42b78cce6b9..ec3773a3e02 100644 --- a/src/aimanager/HISTORY.rst +++ b/src/aimanager/HISTORY.rst @@ -8,7 +8,7 @@ Release History * ``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`` and ``ProvisioningState`` columns. + output with ``Name``, ``ProvisioningState`` and ``Labels`` columns. * ``az aimanager namespace modeldeployment list`` and ``show``: Improve ``-o table`` output with ``Name``, ``ProvisioningState``, ``ModelId`` (human-readable, resolved from the model), ``Replicas`` (current/desired), ``Endpoint`` and ``Namespace`` columns. diff --git a/src/aimanager/azext_aimanager/_format.py b/src/aimanager/azext_aimanager/_format.py index dc102ca68ca..39d9a7672ae 100644 --- a/src/aimanager/azext_aimanager/_format.py +++ b/src/aimanager/azext_aimanager/_format.py @@ -31,12 +31,20 @@ def aimanager_list_table_format(results): 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 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', '')), + ('Labels', _labels_display(properties.get('labels'))), ]) diff --git a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py index 21fd36eebcb..ec5964980cc 100644 --- a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py +++ b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py @@ -76,14 +76,17 @@ def _sample(self): "/namespaces/ns1" ), "name": "ns1", - "properties": {"provisioningState": "Succeeded"}, + "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"], + ["Name", "ProvisioningState", "Labels"], ) self.assertNotIn("ETag", result) @@ -91,16 +94,19 @@ 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") def test_table_format_missing_fields(self): result = namespace_table_format({}) self.assertEqual(result["Name"], "") self.assertEqual(result["ProvisioningState"], "") + 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["Labels"], "") def test_list_table_format(self): results = namespace_list_table_format([self._sample(), self._sample()]) From bd26ba7d88f0c83e412a2c0cf8c7b38776a1218a Mon Sep 17 00:00:00 2001 From: circy9 Date: Thu, 10 Sep 2026 10:40:18 -0700 Subject: [PATCH 10/15] {aimanager} Add Age column to namespace table Derive Age from systemData.createdAt and render it kubectl-style (e.g. 45d, 3h12m), placed just before Labels. Best-effort: blank when the timestamp is missing or unparseable. Co-Authored-By: Claude --- src/aimanager/HISTORY.rst | 2 +- src/aimanager/azext_aimanager/_format.py | 33 +++++++++++++++++++ .../tests/latest/test_aimanager_format.py | 7 +++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/aimanager/HISTORY.rst b/src/aimanager/HISTORY.rst index ec3773a3e02..2dfee85300e 100644 --- a/src/aimanager/HISTORY.rst +++ b/src/aimanager/HISTORY.rst @@ -8,7 +8,7 @@ Release History * ``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`` and ``Labels`` columns. + output with ``Name``, ``ProvisioningState``, ``Age`` and ``Labels`` columns. * ``az aimanager namespace modeldeployment list`` and ``show``: Improve ``-o table`` output with ``Name``, ``ProvisioningState``, ``ModelId`` (human-readable, resolved from the model), ``Replicas`` (current/desired), ``Endpoint`` and ``Namespace`` columns. diff --git a/src/aimanager/azext_aimanager/_format.py b/src/aimanager/azext_aimanager/_format.py index 39d9a7672ae..c55df7a4c70 100644 --- a/src/aimanager/azext_aimanager/_format.py +++ b/src/aimanager/azext_aimanager/_format.py @@ -38,12 +38,45 @@ def _labels_display(labels): 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'))), ]) diff --git a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py index ec5964980cc..7a90ca13a08 100644 --- a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py +++ b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py @@ -76,6 +76,7 @@ def _sample(self): "/namespaces/ns1" ), "name": "ns1", + "systemData": {"createdAt": "2020-01-01T00:00:00+00:00"}, "properties": { "provisioningState": "Succeeded", "labels": {"team": "payments", "env": "prod"}, @@ -86,7 +87,7 @@ def test_table_format_columns(self): result = namespace_table_format(self._sample()) self.assertEqual( list(result.keys()), - ["Name", "ProvisioningState", "Labels"], + ["Name", "ProvisioningState", "Age", "Labels"], ) self.assertNotIn("ETag", result) @@ -95,17 +96,21 @@ def test_table_format_values(self): 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): From ed43d1fe20d3ec03c3c701a3be9b7030e37dcd15 Mon Sep 17 00:00:00 2001 From: circy9 Date: Thu, 10 Sep 2026 10:43:05 -0700 Subject: [PATCH 11/15] {aimanager} Reorder modeldeployment table and add Age Columns are now Namespace, Name, ProvisioningState, Replicas, Age, ModelId, Endpoint. Age is derived from systemData.createdAt. Co-Authored-By: Claude --- src/aimanager/HISTORY.rst | 4 ++-- src/aimanager/azext_aimanager/_format.py | 5 +++-- .../tests/latest/test_aimanager_format.py | 11 +++++++---- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/aimanager/HISTORY.rst b/src/aimanager/HISTORY.rst index 2dfee85300e..7a2771557c8 100644 --- a/src/aimanager/HISTORY.rst +++ b/src/aimanager/HISTORY.rst @@ -10,8 +10,8 @@ Release History * ``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 ``Name``, ``ProvisioningState``, ``ModelId`` (human-readable, resolved from the model), - ``Replicas`` (current/desired), ``Endpoint`` and ``Namespace`` columns. + with ``Namespace``, ``Name``, ``ProvisioningState``, ``Replicas`` (current/desired), + ``Age``, ``ModelId`` (human-readable, resolved from the model) and ``Endpoint`` columns. 1.5.2b1 ++++++ diff --git a/src/aimanager/azext_aimanager/_format.py b/src/aimanager/azext_aimanager/_format.py index c55df7a4c70..b46d607303c 100644 --- a/src/aimanager/azext_aimanager/_format.py +++ b/src/aimanager/azext_aimanager/_format.py @@ -111,12 +111,13 @@ def modeldeployment_table_format(result): ) return OrderedDict([ + ('Namespace', parsed.get('child_name_1', '')), ('Name', result.get('name', '')), ('ProvisioningState', properties.get('provisioningState', '')), - ('ModelId', model_id or ''), ('Replicas', replicas), + ('Age', _age_display(result)), + ('ModelId', model_id or ''), ('Endpoint', status.get('endpoint', '')), - ('Namespace', parsed.get('child_name_1', '')), ]) diff --git a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py index 7a90ca13a08..872df847737 100644 --- a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py +++ b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py @@ -132,6 +132,7 @@ def _sample(self): ), "name": "md1", "modelId": "meta-llama/Llama-3-8B", + "systemData": {"createdAt": "2020-01-01T00:00:00+00:00"}, "properties": { "provisioningState": "Succeeded", "modelResourceId": ( @@ -151,18 +152,19 @@ def test_table_format_columns(self): result = modeldeployment_table_format(self._sample()) self.assertEqual( list(result.keys()), - ["Name", "ProvisioningState", "ModelId", "Replicas", - "Endpoint", "Namespace"], + ["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["ModelId"], "meta-llama/Llama-3-8B") 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") - self.assertEqual(result["Namespace"], "ns1") def test_model_id_fallback_to_resource_name(self): sample = self._sample() @@ -175,6 +177,7 @@ def test_replicas_missing_status(self): 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()]) From d7fb7029aa1435dfb6387aec2b873ffded10e2a3 Mon Sep 17 00:00:00 2001 From: circy9 Date: Thu, 10 Sep 2026 10:45:41 -0700 Subject: [PATCH 12/15] {aimanager} Clean up table format tests per review Remove the unused eTag sample field and the redundant assertNotIn("ETag") assertions; the exact key-list assertions already prove ETag is absent. Co-Authored-By: Claude --- .../azext_aimanager/tests/latest/test_aimanager_format.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py index 872df847737..b814890d2e2 100644 --- a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py +++ b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py @@ -27,7 +27,6 @@ def _sample(self): ), "name": "aimbyo", "location": "westus2", - "eTag": "b918e441-390c-4b01-a922-dea9b42a03df", "properties": {"provisioningState": "Succeeded"}, } @@ -37,7 +36,6 @@ def test_table_format_columns(self): list(result.keys()), ["Name", "ProvisioningState", "ResourceGroup", "Location"], ) - self.assertNotIn("ETag", result) def test_table_format_values(self): result = aimanager_table_format(self._sample()) @@ -89,7 +87,6 @@ def test_table_format_columns(self): list(result.keys()), ["Name", "ProvisioningState", "Age", "Labels"], ) - self.assertNotIn("ETag", result) def test_table_format_values(self): result = namespace_table_format(self._sample()) From 8fc833021e326078322f12ed605859377484f574 Mon Sep 17 00:00:00 2001 From: circy9 Date: Thu, 10 Sep 2026 10:51:38 -0700 Subject: [PATCH 13/15] {aimanager} Show blank ModelId when resolution fails Do not fall back to the raw AIModel resource name (not human-readable); leave the ModelId column blank when the human-readable id is unavailable. Co-Authored-By: Claude --- src/aimanager/azext_aimanager/_format.py | 10 ++++------ .../tests/latest/test_aimanager_format.py | 6 ++++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/aimanager/azext_aimanager/_format.py b/src/aimanager/azext_aimanager/_format.py index b46d607303c..f71000bce89 100644 --- a/src/aimanager/azext_aimanager/_format.py +++ b/src/aimanager/azext_aimanager/_format.py @@ -99,11 +99,9 @@ def modeldeployment_table_format(result): # ``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. Fall back to the AIModel resource name when resolution is unavailable. - model_id = result.get('modelId') - if not model_id: - model_ref = _parse_resource_id(properties.get('modelResourceId', '')) - model_id = model_ref.get('resource_name', '') + # 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')), @@ -116,7 +114,7 @@ def modeldeployment_table_format(result): ('ProvisioningState', properties.get('provisioningState', '')), ('Replicas', replicas), ('Age', _age_display(result)), - ('ModelId', model_id or ''), + ('ModelId', model_id), ('Endpoint', status.get('endpoint', '')), ]) diff --git a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py index b814890d2e2..e681bb19186 100644 --- a/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py +++ b/src/aimanager/azext_aimanager/tests/latest/test_aimanager_format.py @@ -163,11 +163,13 @@ def test_table_format_values(self): self.assertEqual(result["ModelId"], "meta-llama/Llama-3-8B") self.assertEqual(result["Endpoint"], "https://md1.example.com") - def test_model_id_fallback_to_resource_name(self): + 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"], "llama3") + self.assertEqual(result["ModelId"], "") def test_replicas_missing_status(self): result = modeldeployment_table_format({"name": "md1", "properties": None}) From 35dab0f0c0305b91da081f434f587b40b2ba43f8 Mon Sep 17 00:00:00 2001 From: circy9 Date: Thu, 10 Sep 2026 14:38:08 -0700 Subject: [PATCH 14/15] {aimanager} Fix empty ModelId by returning plain dicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolved modelId was injected as an extra attribute on the ModelDeployment SDK model, but azure-cli core 2.76+ copies only declared fields when converting a model for output, silently dropping it — so the ModelId column always rendered blank. Return plain dicts from list/show_modeldeployment with modelId set, so the value survives output conversion. Add regression tests covering the plain-dict result, per-model memoization, and blank-on-failure. Reported by @xmzhao0822; root cause diagnosed by @PugDeveloper. Co-Authored-By: Claude --- src/aimanager/azext_aimanager/custom.py | 31 +++++--- .../tests/latest/test_modeldeployment.py | 72 +++++++++++++++++++ 2 files changed, 92 insertions(+), 11 deletions(-) diff --git a/src/aimanager/azext_aimanager/custom.py b/src/aimanager/azext_aimanager/custom.py index 1da911381bd..1234eaf389e 100644 --- a/src/aimanager/azext_aimanager/custom.py +++ b/src/aimanager/azext_aimanager/custom.py @@ -599,47 +599,55 @@ def show_modeldeployment(cmd, client, resource_group_name, ai_manager_name, name model_deployment_name): # pylint: disable=unused-argument deployment = client.get( resource_group_name, ai_manager_name, namespace_name, model_deployment_name) - _annotate_model_ids(cmd, [deployment]) - return deployment + return _annotate_model_ids(cmd, [deployment])[0] def list_modeldeployment(cmd, client, resource_group_name, ai_manager_name, namespace_name): # pylint: disable=unused-argument - deployments = list(client.list_by_ai_manager_namespace( - resource_group_name, ai_manager_name, namespace_name)) - _annotate_model_ids(cmd, deployments) - return deployments + 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 stash it on the deployment as ``modelId`` for table - rendering. + 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 left unchanged and table output - falls back to the AIModel resource name parsed from the id. + 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 azext_aimanager._client_factory import cf_ai_models ai_models_client = None resolved = {} # (location, ai_model_name) -> modelId + results = [] for deployment in deployments: + # Normalize to a plain dict so an injected ``modelId`` survives CLI output conversion. + deployment = dict(deployment) try: properties = deployment.get('properties') or {} model_resource_id = properties.get('modelResourceId') if not model_resource_id: + results.append(deployment) 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: + results.append(deployment) continue key = (location, ai_model_name) @@ -655,7 +663,8 @@ def _annotate_model_ids(cmd, deployments): except Exception: # pylint: disable=broad-except logger.debug("Failed to resolve human-readable modelId for a model deployment.", exc_info=True) - return deployments + results.append(deployment) + return results def delete_modeldeployment(cmd, client, resource_group_name, ai_manager_name, namespace_name, diff --git a/src/aimanager/azext_aimanager/tests/latest/test_modeldeployment.py b/src/aimanager/azext_aimanager/tests/latest/test_modeldeployment.py index dffd1fe5fe9..73a991d28df 100644 --- a/src/aimanager/azext_aimanager/tests/latest/test_modeldeployment.py +++ b/src/aimanager/azext_aimanager/tests/latest/test_modeldeployment.py @@ -91,6 +91,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() From ca64068b7fdd06cd921d386604afac027fe7bc5e Mon Sep 17 00:00:00 2001 From: circy9 Date: Thu, 10 Sep 2026 14:47:09 -0700 Subject: [PATCH 15/15] {aimanager} Use todict for modeldeployment dict conversion Adopt reviewer suggestion: convert deployments (and the resolved AIModel) with azure.cli.core.util.todict instead of dict(). todict recurses into nested objects, so properties/status become plain dicts too and the camelCase JSON keys the formatter reads are preserved. Co-Authored-By: Claude --- src/aimanager/azext_aimanager/custom.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/aimanager/azext_aimanager/custom.py b/src/aimanager/azext_aimanager/custom.py index 1234eaf389e..4025fb52061 100644 --- a/src/aimanager/azext_aimanager/custom.py +++ b/src/aimanager/azext_aimanager/custom.py @@ -627,27 +627,28 @@ def _annotate_model_ids(cmd, deployments): ``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 - results = [] - for deployment in deployments: - # Normalize to a plain dict so an injected ``modelId`` survives CLI output conversion. - deployment = dict(deployment) + for deployment in annotated: try: properties = deployment.get('properties') or {} model_resource_id = properties.get('modelResourceId') if not model_resource_id: - results.append(deployment) 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: - results.append(deployment) continue key = (location, ai_model_name) @@ -655,7 +656,7 @@ def _annotate_model_ids(cmd, deployments): 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] = (model.get('properties') or {}).get('modelId') + resolved[key] = (todict(model).get('properties') or {}).get('modelId') model_id = resolved[key] if model_id: @@ -663,8 +664,7 @@ def _annotate_model_ids(cmd, deployments): except Exception: # pylint: disable=broad-except logger.debug("Failed to resolve human-readable modelId for a model deployment.", exc_info=True) - results.append(deployment) - return results + return annotated def delete_modeldeployment(cmd, client, resource_group_name, ai_manager_name, namespace_name,