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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/aimanager/HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions src/aimanager/azext_aimanager/_format.py
Original file line number Diff line number Diff line change
@@ -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]
20 changes: 14 additions & 6 deletions src/aimanager/azext_aimanager/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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")

Expand Down
64 changes: 62 additions & 2 deletions src/aimanager/azext_aimanager/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Comment thread
circy9 marked this conversation as resolved.
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
Comment thread
circy9 marked this conversation as resolved.

# 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

Comment thread
circy9 marked this conversation as resolved.
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,
Expand Down
Loading
Loading