From 79261eee92884e450dbd6ac7f956b65d3fb9c22e Mon Sep 17 00:00:00 2001 From: Gloria Huang <179177515+gloriahxr@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:21:03 -0700 Subject: [PATCH 1/3] [AI Manager] Add namespace access key commands Add two commands to the 'az aimanager namespace' group, backed by the AIManagerNamespaces operations of the vendored 2026-05-02-preview SDK: * az aimanager namespace list-accesskeys -> POST .../namespaces/{name}/listAccessKeys * az aimanager namespace rotate-accesskeys -> POST .../namespaces/{name}/rotateKeys Both return the NamespaceAccessInfo payload with the OpenAI-compatible gateway endpoint and the primary/secondary API keys. 'rotate-accesskeys' requires confirmation because it invalidates the previous secondary key. Also accept '--aimanager-name' as an alias of '--manager'/'-m' on the namespace group, matching the option name already used by 'az aimanager namespace modeldeployment'. The existing options keep working. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/aimanager/HISTORY.rst | 7 +++ src/aimanager/azext_aimanager/_help.py | 27 +++++++++ src/aimanager/azext_aimanager/_params.py | 7 ++- .../azext_aimanager/azext_metadata.json | 2 +- src/aimanager/azext_aimanager/commands.py | 2 + src/aimanager/azext_aimanager/custom.py | 24 ++++++++ .../tests/latest/test_namespace_accesskeys.py | 52 ++++++++++++++++ .../test_namespace_accesskeys_scenario.py | 60 +++++++++++++++++++ src/aimanager/setup.py | 2 +- 9 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 src/aimanager/azext_aimanager/tests/latest/test_namespace_accesskeys.py create mode 100644 src/aimanager/azext_aimanager/tests/latest/test_namespace_accesskeys_scenario.py diff --git a/src/aimanager/HISTORY.rst b/src/aimanager/HISTORY.rst index f3812405cfd..ff644f33eb2 100644 --- a/src/aimanager/HISTORY.rst +++ b/src/aimanager/HISTORY.rst @@ -3,6 +3,13 @@ Release History =============== +1.2.1b1 ++++++++ +* ``az aimanager namespace``: Add ``list-accesskeys`` and ``rotate-accesskeys`` commands to + read and rotate the namespace LLM gateway API keys. +* ``az aimanager namespace``: Accept ``--aimanager-name`` as an alias of ``--manager``/``-m`` + for consistency with ``az aimanager namespace modeldeployment``. + 1.2.0 ++++++ * Add ``az aimanager namespace modeldeployment`` commands to add, update, list, show, delete, diff --git a/src/aimanager/azext_aimanager/_help.py b/src/aimanager/azext_aimanager/_help.py index 4964224cc91..40094028adf 100644 --- a/src/aimanager/azext_aimanager/_help.py +++ b/src/aimanager/azext_aimanager/_help.py @@ -141,6 +141,33 @@ text: az aimanager namespace get-credentials -m my-ai-manager -g myrg --name team-alpha -f - """ +helps['aimanager namespace list-accesskeys'] = """ + type: command + short-summary: List the LLM gateway endpoint and API keys of an AI Manager namespace. + long-summary: |- + Returns the namespace-scoped, OpenAI-compatible inference gateway endpoint together with the + current primary and secondary API keys. Treat the keys as secrets: do not log them or persist + them in plaintext. + examples: + - name: List the access keys of a namespace + text: az aimanager namespace list-accesskeys -g myrg --aimanager-name my-ai-manager --name team-alpha + - name: Show only the gateway endpoint + text: az aimanager namespace list-accesskeys -g myrg --aimanager-name my-ai-manager --name team-alpha --query endpoint -o tsv +""" + +helps['aimanager namespace rotate-accesskeys'] = """ + type: command + short-summary: Rotate the LLM gateway API keys of an AI Manager namespace. + long-summary: |- + A new key is generated and installed as the primary key, and the previous primary key + overwrites the secondary key so clients can roll over without downtime. Returns the updated + access info. Any client still using the previous secondary key will stop being able to + authenticate. + examples: + - name: Rotate the access keys of a namespace + text: az aimanager namespace rotate-accesskeys -g myrg --aimanager-name my-ai-manager --name team-alpha +""" + helps['aimanager namespace modeldeployment'] = """ type: group short-summary: Manage model deployments within an AI Manager namespace. diff --git a/src/aimanager/azext_aimanager/_params.py b/src/aimanager/azext_aimanager/_params.py index b859b8577de..dc7472c05ea 100644 --- a/src/aimanager/azext_aimanager/_params.py +++ b/src/aimanager/azext_aimanager/_params.py @@ -54,7 +54,7 @@ def load_arguments(self, _): help='Comma-separated key=value pairs to specify custom headers.') with self.argument_context('aimanager namespace') as c: - c.argument('ai_manager_name', options_list=['--manager', '-m'], + c.argument('ai_manager_name', options_list=['--aimanager-name', '--manager', '-m'], validator=validate_ai_manager_name, help='The name of the AI Manager resource.') c.argument('namespace_name', options_list=['--name', '-n'], @@ -80,6 +80,11 @@ def load_arguments(self, _): c.argument('aks_custom_headers', options_list=['--aks-custom-headers'], help='Comma-separated key=value pairs to specify custom headers.') + for scope in ['aimanager namespace list-accesskeys', 'aimanager namespace rotate-accesskeys']: + with self.argument_context(scope) as c: + c.argument('aks_custom_headers', options_list=['--aks-custom-headers'], + help='Comma-separated key=value pairs to specify custom headers.') + with self.argument_context('aimanager namespace modeldeployment') as c: c.argument('ai_manager_name', options_list=['--aimanager-name'], validator=validate_ai_manager_name, diff --git a/src/aimanager/azext_aimanager/azext_metadata.json b/src/aimanager/azext_aimanager/azext_metadata.json index 8ad409021bd..bda33e00008 100644 --- a/src/aimanager/azext_aimanager/azext_metadata.json +++ b/src/aimanager/azext_aimanager/azext_metadata.json @@ -1,5 +1,5 @@ { "azext.isPreview": true, "azext.minCliCoreVersion": "2.61.0", - "version": "1.2.0" + "version": "1.2.1b1" } diff --git a/src/aimanager/azext_aimanager/commands.py b/src/aimanager/azext_aimanager/commands.py index d100c263b27..e440b90e675 100644 --- a/src/aimanager/azext_aimanager/commands.py +++ b/src/aimanager/azext_aimanager/commands.py @@ -50,6 +50,8 @@ def load_command_table(self, _): g.custom_command("list", "list_aimanager_namespace") 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") + g.custom_command("rotate-accesskeys", "aimanager_namespace_rotate_accesskeys", confirmation=True) g.wait_command("wait") # aimanager namespace modeldeployment command group diff --git a/src/aimanager/azext_aimanager/custom.py b/src/aimanager/azext_aimanager/custom.py index 6da27d25606..fc7fbdcf0ac 100644 --- a/src/aimanager/azext_aimanager/custom.py +++ b/src/aimanager/azext_aimanager/custom.py @@ -299,6 +299,30 @@ def aimanager_namespace_get_credentials(cmd, resource_group_name, ai_manager_name, namespace_name, headers=headers) _write_kubeconfig(credential_results, path, overwrite_existing, context_name) + +# pylint: disable=unused-argument +def aimanager_namespace_list_accesskeys(cmd, + client, + resource_group_name, + ai_manager_name, + namespace_name, + aks_custom_headers=None): + headers = get_aks_custom_headers(aks_custom_headers) + return client.list_access_keys( + resource_group_name, ai_manager_name, namespace_name, headers=headers) + + +# pylint: disable=unused-argument +def aimanager_namespace_rotate_accesskeys(cmd, + client, + resource_group_name, + ai_manager_name, + namespace_name, + aks_custom_headers=None): + headers = get_aks_custom_headers(aks_custom_headers) + return client.rotate_keys( + resource_group_name, ai_manager_name, namespace_name, headers=headers) + # endregion diff --git a/src/aimanager/azext_aimanager/tests/latest/test_namespace_accesskeys.py b/src/aimanager/azext_aimanager/tests/latest/test_namespace_accesskeys.py new file mode 100644 index 00000000000..573f8aa2999 --- /dev/null +++ b/src/aimanager/azext_aimanager/tests/latest/test_namespace_accesskeys.py @@ -0,0 +1,52 @@ +# -------------------------------------------------------------------------------------------- +# 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 unittest.mock import MagicMock + +from azext_aimanager import custom + + +class MockCmd: + pass + + +class TestNamespaceAccessKeys(unittest.TestCase): + + def setUp(self): + self.cmd = MockCmd() + self.client = MagicMock() + + def test_list_accesskeys(self): + self.client.list_access_keys.return_value = "access-info" + + result = custom.aimanager_namespace_list_accesskeys( + self.cmd, self.client, "rg", "manager", "namespace") + + self.assertEqual(result, "access-info") + self.client.list_access_keys.assert_called_once_with( + "rg", "manager", "namespace", headers={}) + + def test_rotate_accesskeys(self): + self.client.rotate_keys.return_value = "rotated" + + result = custom.aimanager_namespace_rotate_accesskeys( + self.cmd, self.client, "rg", "manager", "namespace") + + self.assertEqual(result, "rotated") + self.client.rotate_keys.assert_called_once_with( + "rg", "manager", "namespace", headers={}) + + def test_custom_headers_are_forwarded(self): + custom.aimanager_namespace_list_accesskeys( + self.cmd, self.client, "rg", "manager", "namespace", + aks_custom_headers="a=1,b=2") + + self.client.list_access_keys.assert_called_once_with( + "rg", "manager", "namespace", headers={"a": "1", "b": "2"}) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/aimanager/azext_aimanager/tests/latest/test_namespace_accesskeys_scenario.py b/src/aimanager/azext_aimanager/tests/latest/test_namespace_accesskeys_scenario.py new file mode 100644 index 00000000000..eb2e2b898f9 --- /dev/null +++ b/src/aimanager/azext_aimanager/tests/latest/test_namespace_accesskeys_scenario.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. +# -------------------------------------------------------------------------------------------- + +from unittest.mock import MagicMock, patch + +from azure.cli.testsdk import ScenarioTest + +from azext_aimanager.vendored_sdks.v2026_05_02_preview import models + + +class NamespaceAccessKeysScenarioTest(ScenarioTest): + + def test_namespace_accesskeys_commands(self): + access_info = models.NamespaceAccessInfo({ + 'endpoint': 'https://team-alpha.example.eastus2.aksapp.io/v1', + 'primaryKey': 'primary-key-value', + 'secondaryKey': 'secondary-key-value', + }) + rotated_info = models.NamespaceAccessInfo({ + 'endpoint': 'https://team-alpha.example.eastus2.aksapp.io/v1', + 'primaryKey': 'new-primary-key-value', + 'secondaryKey': 'primary-key-value', + 'lastRotatedAt': '2026-08-14T00:00:00Z', + }) + + operations = MagicMock() + operations.list_access_keys.return_value = access_info + operations.rotate_keys.return_value = rotated_info + service_client = MagicMock() + service_client.ai_manager_namespaces = operations + + command_prefix = 'aimanager namespace {} -g rg --aimanager-name manager -n namespace' + + with patch('azext_aimanager._client_factory.get_aimanager_client', + return_value=service_client): + self.cmd( + command_prefix.format('list-accesskeys'), + checks=[ + self.check('endpoint', 'https://team-alpha.example.eastus2.aksapp.io/v1'), + self.check('primaryKey', 'primary-key-value'), + self.check('secondaryKey', 'secondary-key-value'), + ]) + + # the previous primary key is demoted to the secondary key on rotation + self.cmd( + command_prefix.format('rotate-accesskeys') + ' --yes', + checks=[ + self.check('primaryKey', 'new-primary-key-value'), + self.check('secondaryKey', 'primary-key-value'), + ]) + + # -m/--manager remains accepted for backwards compatibility + self.cmd( + 'aimanager namespace list-accesskeys -g rg -m manager -n namespace', + checks=[self.check('primaryKey', 'primary-key-value')]) + + operations.list_access_keys.assert_called_with('rg', 'manager', 'namespace', headers={}) + operations.rotate_keys.assert_called_once_with('rg', 'manager', 'namespace', headers={}) diff --git a/src/aimanager/setup.py b/src/aimanager/setup.py index 399850f3d04..11bac3528e6 100644 --- a/src/aimanager/setup.py +++ b/src/aimanager/setup.py @@ -14,7 +14,7 @@ from distutils import log as logger logger.warn("Wheel is not available, disabling bdist_wheel hook") -VERSION = '1.2.0' +VERSION = '1.2.1b1' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers From 31a8f8693975594c63582b25c97c127b627bb226 Mon Sep 17 00:00:00 2001 From: Gloria Huang <179177515+gloriahxr@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:03:18 -0700 Subject: [PATCH 2/3] Cover --aks-custom-headers forwarding in the scenario test Add a CLI invocation that passes --aks-custom-headers and assert the parsed headers reach the SDK call, so the public option name and its wiring are pinned end-to-end rather than only at the function level. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../latest/test_namespace_accesskeys_scenario.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/aimanager/azext_aimanager/tests/latest/test_namespace_accesskeys_scenario.py b/src/aimanager/azext_aimanager/tests/latest/test_namespace_accesskeys_scenario.py index eb2e2b898f9..96915c69928 100644 --- a/src/aimanager/azext_aimanager/tests/latest/test_namespace_accesskeys_scenario.py +++ b/src/aimanager/azext_aimanager/tests/latest/test_namespace_accesskeys_scenario.py @@ -56,5 +56,14 @@ def test_namespace_accesskeys_commands(self): 'aimanager namespace list-accesskeys -g rg -m manager -n namespace', checks=[self.check('primaryKey', 'primary-key-value')]) - operations.list_access_keys.assert_called_with('rg', 'manager', 'namespace', headers={}) + operations.list_access_keys.assert_called_with( + 'rg', 'manager', 'namespace', headers={}) + + # --aks-custom-headers is parsed and forwarded to the request + self.cmd( + command_prefix.format('list-accesskeys') + ' --aks-custom-headers a=1,b=2', + checks=[self.check('primaryKey', 'primary-key-value')]) + + operations.list_access_keys.assert_called_with( + 'rg', 'manager', 'namespace', headers={'a': '1', 'b': '2'}) operations.rotate_keys.assert_called_once_with('rg', 'manager', 'namespace', headers={}) From 59766e793b26bdb75c954009ab9658572ee1b8a8 Mon Sep 17 00:00:00 2001 From: Gloria Huang <179177515+gloriahxr@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:47:41 -0700 Subject: [PATCH 3/3] Cover all three aimanager PRs in the 1.2.1b1 changelog Per review feedback, this PR carries the single version bump and the combined changelog for the three related aimanager PRs, so they release once instead of three times. The model and modelsource PRs now carry code only. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/aimanager/HISTORY.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/aimanager/HISTORY.rst b/src/aimanager/HISTORY.rst index ff644f33eb2..857a6232f74 100644 --- a/src/aimanager/HISTORY.rst +++ b/src/aimanager/HISTORY.rst @@ -5,6 +5,10 @@ Release History 1.2.1b1 +++++++ +* Add ``az aimanager model`` commands (``show``, ``list`` and ``calculate-cost``) to browse the + regional AI model catalog and estimate the cost of deploying a model. +* Add ``az aimanager modelsource`` commands (``add``, ``update``, ``list``, ``show``, + ``delete`` and ``wait``) to manage the model sources of an AI Manager. * ``az aimanager namespace``: Add ``list-accesskeys`` and ``rotate-accesskeys`` commands to read and rotate the namespace LLM gateway API keys. * ``az aimanager namespace``: Accept ``--aimanager-name`` as an alias of ``--manager``/``-m``