diff --git a/sdk/ai/azure-ai-projects/.env.template b/sdk/ai/azure-ai-projects/.env.template index 620367d29578..6ffe61b712d7 100644 --- a/sdk/ai/azure-ai-projects/.env.template +++ b/sdk/ai/azure-ai-projects/.env.template @@ -21,6 +21,7 @@ AZURE_AI_PROJECTS_CONSOLE_LOGGING= # Project endpoint has the format: # `https://.services.ai.azure.com/api/projects/` FOUNDRY_PROJECT_ENDPOINT= +FOUNDRY_PROJECT_API_KEY= FOUNDRY_MODEL_NAME= FOUNDRY_AGENT_NAME= CONVERSATION_ID= diff --git a/sdk/ai/azure-ai-projects/README.md b/sdk/ai/azure-ai-projects/README.md index 93d3cd788fb8..8bb065bc3893 100644 --- a/sdk/ai/azure-ai-projects/README.md +++ b/sdk/ai/azure-ai-projects/README.md @@ -55,7 +55,8 @@ To report an issue with the client library, or request additional features, plea * An [Azure subscription][azure_sub]. * A [project in Microsoft Foundry](https://learn.microsoft.com/azure/foundry/how-to/create-projects). * A Foundry project endpoint URL of the form `https://your-ai-services-account-name.services.ai.azure.com/api/projects/your-project-name`. It can be found in your Microsoft Foundry Project home page. Below we will assume the environment variable `FOUNDRY_PROJECT_ENDPOINT` was defined to hold this value. -* An Entra ID token for authentication. Your application needs an object that implements the [TokenCredential](https://learn.microsoft.com/python/api/azure-core/azure.core.credentials.tokencredential) interface. Code samples here use [DefaultAzureCredential](https://learn.microsoft.com/python/api/azure-identity/azure.identity.defaultazurecredential). To get that working, you will need: +* To authenticate using API key, you will need the "Project API key" as shown in your Microsoft Foundry Project home page. +* To authenticate using Entra ID, your application needs an object that implements the [TokenCredential](https://learn.microsoft.com/python/api/azure-core/azure.core.credentials.tokencredential) interface. Code samples here use [DefaultAzureCredential](https://learn.microsoft.com/python/api/azure-identity/azure.identity.defaultazurecredential). To get that working, you will need: * An appropriate role assignment. See [Role-based access control in Microsoft Foundry portal](https://learn.microsoft.com/azure/foundry/concepts/rbac-foundry). Role assignment can be done via the "Access Control (IAM)" tab of your Azure AI Project resource in the Azure portal. * [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) installed. * You are logged into your Azure account by running `az login`. @@ -74,9 +75,46 @@ pip show azure-ai-projects ## Key concepts -### Create and authenticate the client with Entra ID +### Create and authenticate the client with API key + +To construct a synchronous client using a context manager: + +```python +import os +from azure.core.credentials import AzureKeyCredential +from azure.ai.projects import AIProjectClient + +endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] +api_key = os.environ["FOUNDRY_PROJECT_API_KEY"] + +with ( + AIProjectClient(endpoint=endpoint, credential=AzureKeyCredential(api_key)) as project_client +): +``` -Entra ID is the only authentication method supported at the moment by the client. +To construct an asynchronous client, install the additional package [aiohttp](https://pypi.org/project/aiohttp/): + +```bash +pip install aiohttp +``` + +and run: + +```python +import os +import asyncio +from azure.core.credentials import AzureKeyCredential +from azure.ai.projects.aio import AIProjectClient + +endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] +api_key = os.environ["FOUNDRY_PROJECT_API_KEY"] + +async with ( + AIProjectClient(endpoint=endpoint, credential=AzureKeyCredential(api_key)) as project_client +): +``` + +### Create and authenticate the client with Entra ID To construct a synchronous client using a context manager: @@ -85,9 +123,11 @@ import os from azure.ai.projects import AIProjectClient from azure.identity import DefaultAzureCredential +endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + with ( DefaultAzureCredential() as credential, - AIProjectClient(endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], credential=credential) as project_client, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, ): ``` @@ -105,9 +145,11 @@ import asyncio from azure.ai.projects.aio import AIProjectClient from azure.identity.aio import DefaultAzureCredential +endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + async with ( DefaultAzureCredential() as credential, - AIProjectClient(endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], credential=credential) as project_client, + AIProjectClient(endpoint=endpoint, credential=credential) as project_client, ): ``` diff --git a/sdk/ai/azure-ai-projects/assets.json b/sdk/ai/azure-ai-projects/assets.json index 4d184f12bdb4..3eb2fbb88bb2 100644 --- a/sdk/ai/azure-ai-projects/assets.json +++ b/sdk/ai/azure-ai-projects/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "python", "TagPrefix": "python/ai/azure-ai-projects", - "Tag": "python/ai/azure-ai-projects_5b25ba9450" + "Tag": "python/ai/azure-ai-projects_cbf65e62f5" } diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/_client.py b/sdk/ai/azure-ai-projects/azure/ai/projects/_client.py index c9b4ba135893..635a13982e7b 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/_client.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/_client.py @@ -7,10 +7,11 @@ # -------------------------------------------------------------------------- from copy import deepcopy -from typing import Any, Optional, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING, Union from typing_extensions import Self from azure.core import PipelineClient +from azure.core.credentials import AzureKeyCredential from azure.core.pipeline import policies from azure.core.rest import HttpRequest, HttpResponse @@ -53,8 +54,10 @@ class AIProjectClient: # pylint: disable=too-many-instance-attributes the form "https://{ai-services-account-name}.services.ai.azure.com/api/projects/_project". Required. :type endpoint: str - :param credential: Credential used to authenticate requests to the service. Required. - :type credential: ~azure.core.credentials.TokenCredential + :param credential: Credential used to authenticate requests to the service. Is either a key + credential type or a token credential type. Required. + :type credential: ~azure.core.credentials.AzureKeyCredential or + ~azure.core.credentials.TokenCredential :param allow_preview: Whether to enable preview features. Must be specified and set to True to enable preview features. Default value is None. :type allow_preview: bool @@ -65,7 +68,11 @@ class AIProjectClient: # pylint: disable=too-many-instance-attributes """ def __init__( - self, endpoint: str, credential: "TokenCredential", allow_preview: Optional[bool] = None, **kwargs: Any + self, + endpoint: str, + credential: Union[AzureKeyCredential, "TokenCredential"], + allow_preview: Optional[bool] = None, + **kwargs: Any ) -> None: _endpoint = "{endpoint}" self._config = AIProjectClientConfiguration( diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/_configuration.py b/sdk/ai/azure-ai-projects/azure/ai/projects/_configuration.py index 1b9d513e9220..25c0f861fa8b 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/_configuration.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/_configuration.py @@ -6,8 +6,9 @@ # Changes may cause incorrect behavior and will be lost if the code is regenerated. # -------------------------------------------------------------------------- -from typing import Any, Optional, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING, Union +from azure.core.credentials import AzureKeyCredential from azure.core.pipeline import policies from ._version import VERSION @@ -28,8 +29,10 @@ class AIProjectClientConfiguration: # pylint: disable=too-many-instance-attribu the form "https://{ai-services-account-name}.services.ai.azure.com/api/projects/_project". Required. :type endpoint: str - :param credential: Credential used to authenticate requests to the service. Required. - :type credential: ~azure.core.credentials.TokenCredential + :param credential: Credential used to authenticate requests to the service. Is either a key + credential type or a token credential type. Required. + :type credential: ~azure.core.credentials.AzureKeyCredential or + ~azure.core.credentials.TokenCredential :param allow_preview: Whether to enable preview features. Must be specified and set to True to enable preview features. Default value is None. :type allow_preview: bool @@ -40,7 +43,11 @@ class AIProjectClientConfiguration: # pylint: disable=too-many-instance-attribu """ def __init__( - self, endpoint: str, credential: "TokenCredential", allow_preview: Optional[bool] = None, **kwargs: Any + self, + endpoint: str, + credential: Union[AzureKeyCredential, "TokenCredential"], + allow_preview: Optional[bool] = None, + **kwargs: Any, ) -> None: api_version: str = kwargs.pop("api_version", "v1") @@ -58,6 +65,13 @@ def __init__( self.polling_interval = kwargs.get("polling_interval", 30) self._configure(**kwargs) + def _infer_policy(self, **kwargs): + if isinstance(self.credential, AzureKeyCredential): + return policies.AzureKeyCredentialPolicy(self.credential, "api-key", **kwargs) + if hasattr(self.credential, "get_token"): + return policies.BearerTokenCredentialPolicy(self.credential, *self.credential_scopes, **kwargs) + raise TypeError(f"Unsupported credential: {self.credential}") + def _configure(self, **kwargs: Any) -> None: self.user_agent_policy = kwargs.get("user_agent_policy") or policies.UserAgentPolicy(**kwargs) self.headers_policy = kwargs.get("headers_policy") or policies.HeadersPolicy(**kwargs) @@ -69,6 +83,4 @@ def _configure(self, **kwargs: Any) -> None: self.retry_policy = kwargs.get("retry_policy") or policies.RetryPolicy(**kwargs) self.authentication_policy = kwargs.get("authentication_policy") if self.credential and not self.authentication_policy: - self.authentication_policy = policies.BearerTokenCredentialPolicy( - self.credential, *self.credential_scopes, **kwargs - ) + self.authentication_policy = self._infer_policy(**kwargs) diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/_patch.py b/sdk/ai/azure-ai-projects/azure/ai/projects/_patch.py index 98c3e388bb92..9d5ca36cce62 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/_patch.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/_patch.py @@ -11,9 +11,10 @@ import os import re import logging -from typing import List, Any +from typing import List, Any, Union import httpx # pylint: disable=networking-import-outside-azure-core-transport from openai import OpenAI +from azure.core.credentials import AzureKeyCredential from azure.core.tracing.decorator import distributed_trace from azure.core.credentials import TokenCredential from azure.identity import get_bearer_token_provider @@ -46,8 +47,10 @@ class AIProjectClient(AIProjectClientGenerated): # pylint: disable=too-many-ins the form "https://{ai-services-account-name}.services.ai.azure.com/api/projects/_project". Required. :type endpoint: str - :param credential: Credential used to authenticate requests to the service. Required. - :type credential: ~azure.core.credentials.TokenCredential + :param credential: Credential used to authenticate requests to the service. Is either a key + credential type or a token credential type. Required. + :type credential: ~azure.core.credentials.AzureKeyCredential or + ~azure.core.credentials.TokenCredential :param allow_preview: Whether to enable preview features. Optional, default is False. Set this to True to create a Hosted Agent (using :class:`~azure.ai.projects.models.HostedAgentDefinition`) or a Workflow Agent (using :class:`~azure.ai.projects.models.WorkflowAgentDefinition`). @@ -64,7 +67,12 @@ class AIProjectClient(AIProjectClientGenerated): # pylint: disable=too-many-ins """ def __init__( - self, endpoint: str, credential: TokenCredential, *, allow_preview: bool = False, **kwargs: Any + self, + endpoint: str, + credential: Union[AzureKeyCredential, "TokenCredential"], + *, + allow_preview: bool = False, + **kwargs: Any, ) -> None: self._console_logging_enabled: bool = ( @@ -78,7 +86,7 @@ def __init__( azure_logger = logging.getLogger("azure") azure_logger.setLevel(logging.DEBUG) console_handler = logging.StreamHandler(stream=sys.stdout) - console_handler.addFilter(_BearerTokenRedactionFilter()) + console_handler.addFilter(_AuthSecretsFilter()) azure_logger.addHandler(console_handler) # Exclude detailed logs for network calls associated with getting Entra ID token. logging.getLogger("azure.identity").setLevel(logging.ERROR) @@ -105,8 +113,12 @@ def get_openai_client(self, **kwargs: Any) -> "OpenAI": # type: ignore[name-def The OpenAI client constructor is called with: * ``base_url`` set to the endpoint provided to the AIProjectClient constructor, with "/openai/v1" appended. Can be overridden by passing ``base_url`` as a keyword argument. - * ``api_key`` set to a get_bearer_token_provider() callable that uses the TokenCredential provided to the - AIProjectClient constructor, with scope "https://ai.azure.com/.default". + * If :class:`~azure.ai.projects.AIProjectClient` was constructed with a bearer token, ``api_key`` is set + to a get_bearer_token_provider() callable that uses the TokenCredential provided to the AIProjectClient + constructor, with scope ``https://ai.azure.com/.default``. + Can be overridden by passing ``api_key`` as a keyword argument. + * If :class:`~azure.ai.projects.AIProjectClient` was constructed with ``api-key``, it is passed to the + OpenAI constructor as is. Can be overridden by passing ``api_key`` as a keyword argument. .. note:: The packages ``openai`` and ``azure.identity`` must be installed prior to calling this method. @@ -130,13 +142,17 @@ def get_openai_client(self, **kwargs: Any) -> "OpenAI": # type: ignore[name-def base_url, ) - # Allow caller to override api_key, otherwise use token provider + # Allow caller to override api_key, otherwise use api-key or token provider given during AIProjectClient constructor if "api_key" in kwargs: api_key = kwargs.pop("api_key") else: - api_key = get_bearer_token_provider( - self._config.credential, # pylint: disable=protected-access - "https://ai.azure.com/.default", + api_key = ( + self._config.credential.key # pylint: disable=protected-access + if isinstance(self._config.credential, AzureKeyCredential) + else get_bearer_token_provider( + self._config.credential, # pylint: disable=protected-access + "https://ai.azure.com/.default", + ) ) if "http_client" in kwargs: @@ -178,16 +194,21 @@ def _create_openai_client(**kwargs) -> OpenAI: return client -class _BearerTokenRedactionFilter(logging.Filter): - """Redact bearer tokens in azure.core log messages before they are emitted to console.""" +class _AuthSecretsFilter(logging.Filter): + """Redact bearer tokens and api-key values in azure.core log messages before they are emitted to console.""" _AUTH_HEADER_DICT_PATTERN = re.compile( - r"(?i)(['\"]authorization['\"]\s*:\s*['\"])bearer\s+[^'\"]+(['\"])", + r"(?i)(['\"]authorization['\"]\ *:\ *['\"])bearer\s+[^'\"]+(['\"])", + ) + + _API_KEY_HEADER_DICT_PATTERN = re.compile( + r"(?i)(['\"]api-key['\"]\ *:\ *['\"])[^'\"]+(['\"])", ) def filter(self, record: logging.LogRecord) -> bool: rendered = record.getMessage() redacted = self._AUTH_HEADER_DICT_PATTERN.sub(r"\1Bearer \2", rendered) + redacted = self._API_KEY_HEADER_DICT_PATTERN.sub(r"\1\2", redacted) if redacted != rendered: # Replace the pre-formatted content so handlers emit sanitized output. record.msg = redacted @@ -208,7 +229,7 @@ class OpenAILoggingTransport(httpx.HTTPTransport): """ def _sanitize_auth_header(self, headers) -> None: - """Sanitize authorization header by redacting sensitive information. + """Sanitize authorization and api-key headers by redacting sensitive information. :param headers: Dictionary of HTTP headers to sanitize :type headers: dict diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/_utils/model_base.py b/sdk/ai/azure-ai-projects/azure/ai/projects/_utils/model_base.py index 9616929f7415..a75a22adbb97 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/_utils/model_base.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/_utils/model_base.py @@ -630,6 +630,9 @@ def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: if len(items) > 0: existed_attr_keys.append(xml_name) dict_to_pass[rf._rest_name] = _deserialize(rf._type, items) + elif not rf._is_optional: + existed_attr_keys.append(xml_name) + dict_to_pass[rf._rest_name] = [] continue # text element is primitive type @@ -905,6 +908,8 @@ def _get_deserialize_callable_from_annotation( # pylint: disable=too-many-retur # is it optional? try: if any(a is _NONE_TYPE for a in annotation.__args__): # pyright: ignore + if rf: + rf._is_optional = True if len(annotation.__args__) <= 2: # pyright: ignore if_obj_deserializer = _get_deserialize_callable_from_annotation( next(a for a in annotation.__args__ if a is not _NONE_TYPE), module, rf # pyright: ignore @@ -1084,6 +1089,7 @@ def __init__( self._is_discriminator = is_discriminator self._visibility = visibility self._is_model = False + self._is_optional = False self._default = default self._format = format self._is_multipart_file_input = is_multipart_file_input diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/_client.py b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/_client.py index 086ddbab3fd9..3fadfc6efc2f 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/_client.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/_client.py @@ -7,10 +7,11 @@ # -------------------------------------------------------------------------- from copy import deepcopy -from typing import Any, Awaitable, Optional, TYPE_CHECKING +from typing import Any, Awaitable, Optional, TYPE_CHECKING, Union from typing_extensions import Self from azure.core import AsyncPipelineClient +from azure.core.credentials import AzureKeyCredential from azure.core.pipeline import policies from azure.core.rest import AsyncHttpResponse, HttpRequest @@ -53,8 +54,10 @@ class AIProjectClient: # pylint: disable=too-many-instance-attributes the form "https://{ai-services-account-name}.services.ai.azure.com/api/projects/_project". Required. :type endpoint: str - :param credential: Credential used to authenticate requests to the service. Required. - :type credential: ~azure.core.credentials_async.AsyncTokenCredential + :param credential: Credential used to authenticate requests to the service. Is either a key + credential type or a token credential type. Required. + :type credential: ~azure.core.credentials.AzureKeyCredential or + ~azure.core.credentials_async.AsyncTokenCredential :param allow_preview: Whether to enable preview features. Must be specified and set to True to enable preview features. Default value is None. :type allow_preview: bool @@ -65,7 +68,11 @@ class AIProjectClient: # pylint: disable=too-many-instance-attributes """ def __init__( - self, endpoint: str, credential: "AsyncTokenCredential", allow_preview: Optional[bool] = None, **kwargs: Any + self, + endpoint: str, + credential: Union[AzureKeyCredential, "AsyncTokenCredential"], + allow_preview: Optional[bool] = None, + **kwargs: Any ) -> None: _endpoint = "{endpoint}" self._config = AIProjectClientConfiguration( diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/_configuration.py b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/_configuration.py index 7fd12934a2a8..bd8d890b071b 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/_configuration.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/_configuration.py @@ -6,8 +6,9 @@ # Changes may cause incorrect behavior and will be lost if the code is regenerated. # -------------------------------------------------------------------------- -from typing import Any, Optional, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING, Union +from azure.core.credentials import AzureKeyCredential from azure.core.pipeline import policies from .._version import VERSION @@ -28,8 +29,10 @@ class AIProjectClientConfiguration: # pylint: disable=too-many-instance-attribu the form "https://{ai-services-account-name}.services.ai.azure.com/api/projects/_project". Required. :type endpoint: str - :param credential: Credential used to authenticate requests to the service. Required. - :type credential: ~azure.core.credentials_async.AsyncTokenCredential + :param credential: Credential used to authenticate requests to the service. Is either a key + credential type or a token credential type. Required. + :type credential: ~azure.core.credentials.AzureKeyCredential or + ~azure.core.credentials_async.AsyncTokenCredential :param allow_preview: Whether to enable preview features. Must be specified and set to True to enable preview features. Default value is None. :type allow_preview: bool @@ -40,7 +43,11 @@ class AIProjectClientConfiguration: # pylint: disable=too-many-instance-attribu """ def __init__( - self, endpoint: str, credential: "AsyncTokenCredential", allow_preview: Optional[bool] = None, **kwargs: Any + self, + endpoint: str, + credential: Union[AzureKeyCredential, "AsyncTokenCredential"], + allow_preview: Optional[bool] = None, + **kwargs: Any, ) -> None: api_version: str = kwargs.pop("api_version", "v1") @@ -58,6 +65,13 @@ def __init__( self.polling_interval = kwargs.get("polling_interval", 30) self._configure(**kwargs) + def _infer_policy(self, **kwargs): + if isinstance(self.credential, AzureKeyCredential): + return policies.AzureKeyCredentialPolicy(self.credential, "api-key", **kwargs) + if hasattr(self.credential, "get_token"): + return policies.AsyncBearerTokenCredentialPolicy(self.credential, *self.credential_scopes, **kwargs) + raise TypeError(f"Unsupported credential: {self.credential}") + def _configure(self, **kwargs: Any) -> None: self.user_agent_policy = kwargs.get("user_agent_policy") or policies.UserAgentPolicy(**kwargs) self.headers_policy = kwargs.get("headers_policy") or policies.HeadersPolicy(**kwargs) @@ -69,6 +83,4 @@ def _configure(self, **kwargs: Any) -> None: self.retry_policy = kwargs.get("retry_policy") or policies.AsyncRetryPolicy(**kwargs) self.authentication_policy = kwargs.get("authentication_policy") if self.credential and not self.authentication_policy: - self.authentication_policy = policies.AsyncBearerTokenCredentialPolicy( - self.credential, *self.credential_scopes, **kwargs - ) + self.authentication_policy = self._infer_policy(**kwargs) diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/_patch.py b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/_patch.py index 4f8312c6996e..7172df9225b7 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/_patch.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/_patch.py @@ -10,13 +10,14 @@ import os import logging -from typing import List, Any +from typing import List, Any, Union import httpx # pylint: disable=networking-import-outside-azure-core-transport from openai import AsyncOpenAI +from azure.core.credentials import AzureKeyCredential from azure.core.tracing.decorator import distributed_trace from azure.core.credentials_async import AsyncTokenCredential from azure.identity.aio import get_bearer_token_provider -from .._patch import _BearerTokenRedactionFilter +from .._patch import _AuthSecretsFilter from ._client import AIProjectClient as AIProjectClientGenerated from .operations import TelemetryOperations @@ -46,8 +47,10 @@ class AIProjectClient(AIProjectClientGenerated): # pylint: disable=too-many-ins the form "https://{ai-services-account-name}.services.ai.azure.com/api/projects/_project". Required. :type endpoint: str - :param credential: Credential used to authenticate requests to the service. Required. - :type credential: ~azure.core.credentials_async.AsyncTokenCredential + :param credential: Credential used to authenticate requests to the service. Is either a key + credential type or a token credential type. Required. + :type credential: ~azure.core.credentials.AzureKeyCredential or + ~azure.core.credentials_async.AsyncTokenCredential :param allow_preview: Whether to enable preview features. Optional, default is False. Set this to True to create a Hosted Agent (using :class:`~azure.ai.projects.models.HostedAgentDefinition`) or a Workflow Agent (using :class:`~azure.ai.projects.models.WorkflowAgentDefinition`). @@ -56,6 +59,7 @@ class AIProjectClient(AIProjectClientGenerated): # pylint: disable=too-many-ins are all in preview, but do not require setting `allow_preview=True` since it's implied by the sub-client name. When preview features are enabled, the client libraries sends the HTTP request header `Foundry-Features` with the appropriate value in all relevant calls to the service. + :type allow_preview: bool :keyword api_version: The API version to use for this operation. Known values are "v1" and None. Default value is "v1". Note that overriding this default value may result in unsupported behavior. @@ -63,7 +67,12 @@ class AIProjectClient(AIProjectClientGenerated): # pylint: disable=too-many-ins """ def __init__( - self, endpoint: str, credential: AsyncTokenCredential, *, allow_preview: bool = False, **kwargs: Any + self, + endpoint: str, + credential: Union[AzureKeyCredential, "AsyncTokenCredential"], + *, + allow_preview: bool = False, + **kwargs: Any, ) -> None: self._console_logging_enabled: bool = ( @@ -77,7 +86,7 @@ def __init__( azure_logger = logging.getLogger("azure") azure_logger.setLevel(logging.DEBUG) console_handler = logging.StreamHandler(stream=sys.stdout) - console_handler.addFilter(_BearerTokenRedactionFilter()) + console_handler.addFilter(_AuthSecretsFilter()) azure_logger.addHandler(console_handler) # Exclude detailed logs for network calls associated with getting Entra ID token. logging.getLogger("azure.identity").setLevel(logging.ERROR) @@ -104,8 +113,12 @@ def get_openai_client(self, **kwargs: Any) -> "AsyncOpenAI": # type: ignore[nam The AsyncOpenAI client constructor is called with: * ``base_url`` set to the endpoint provided to the AIProjectClient constructor, with "/openai/v1" appended. Can be overridden by passing ``base_url`` as a keyword argument. - * ``api_key`` set to a get_bearer_token_provider() callable that uses the TokenCredential provided to the - AIProjectClient constructor, with scope "https://ai.azure.com/.default". + * If :class:`~azure.ai.projects.aio.AIProjectClient` was constructed with a bearer token, ``api_key`` is set + to a get_bearer_token_provider() callable that uses the TokenCredential provided to the AIProjectClient + constructor, with scope ``https://ai.azure.com/.default``. + Can be overridden by passing ``api_key`` as a keyword argument. + * If :class:`~azure.ai.projects.aio.AIProjectClient` was constructed with ``api-key``, it is passed to the + OpenAI constructor as is. Can be overridden by passing ``api_key`` as a keyword argument. .. note:: The packages ``openai`` and ``azure.identity`` must be installed prior to calling this method. @@ -129,13 +142,17 @@ def get_openai_client(self, **kwargs: Any) -> "AsyncOpenAI": # type: ignore[nam base_url, ) - # Allow caller to override api_key, otherwise use token provider + # Allow caller to override api_key, otherwise use api-key or token provider given during AIProjectClient constructor if "api_key" in kwargs: api_key = kwargs.pop("api_key") else: - api_key = get_bearer_token_provider( - self._config.credential, # pylint: disable=protected-access - "https://ai.azure.com/.default", + api_key = ( + self._config.credential.key # pylint: disable=protected-access + if isinstance(self._config.credential, AzureKeyCredential) + else get_bearer_token_provider( + self._config.credential, # pylint: disable=protected-access + "https://ai.azure.com/.default", + ) ) if "http_client" in kwargs: @@ -190,7 +207,7 @@ class OpenAILoggingTransport(httpx.AsyncHTTPTransport): """ def _sanitize_auth_header(self, headers): - """Sanitize authorization header by redacting sensitive information. + """Sanitize authorization and api-key headers by redacting sensitive information. :param headers: Dictionary of HTTP headers to sanitize :type headers: dict diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_operations.py b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_operations.py index 5daa5483b071..3e78f121e181 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_operations.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_operations.py @@ -512,6 +512,7 @@ async def create_version( :raises ~azure.core.exceptions.HttpResponseError: """ _foundry_features: Optional[str] = _get_agent_definition_opt_in_keys if self._config.allow_preview else None # type: ignore + error_map: MutableMapping = { 401: ClientAuthenticationError, 404: ResourceNotFoundError, diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch.py b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch.py index fd4e68774f3b..9e5b1872d76d 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/aio/operations/_patch.py @@ -19,9 +19,10 @@ BetaEvaluationTaxonomiesOperations, BetaEvaluatorsOperations, BetaInsightsOperations, + BetaOperations as GeneratedBetaOperations, BetaRedTeamsOperations, BetaSchedulesOperations, - BetaOperations as GeneratedBetaOperations, + BetaToolsetsOperations, ) @@ -47,6 +48,8 @@ class BetaOperations(GeneratedBetaOperations): """:class:`~azure.ai.projects.aio.operations.BetaRedTeamsOperations` operations""" schedules: BetaSchedulesOperations """:class:`~azure.ai.projects.aio.operations.BetaSchedulesOperations` operations""" + toolsets: BetaToolsetsOperations + """:class:`~azure.ai.projects.operations.BetaToolsetsOperations` operations""" def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) @@ -57,15 +60,16 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: __all__: List[str] = [ "AgentsOperations", "BetaEvaluationTaxonomiesOperations", - "EvaluationRulesOperations", "BetaEvaluatorsOperations", "BetaInsightsOperations", "BetaMemoryStoresOperations", "BetaOperations", "BetaRedTeamsOperations", "BetaSchedulesOperations", + "BetaToolsetsOperations", "ConnectionsOperations", "DatasetsOperations", + "EvaluationRulesOperations", "TelemetryOperations", ] # Add all objects you want publicly available to users at this package level diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/models/_enums.py b/sdk/ai/azure-ai-projects/azure/ai/projects/models/_enums.py index 923dd800ef60..fff2adb637c4 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/models/_enums.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/models/_enums.py @@ -373,6 +373,8 @@ class _FoundryFeaturesOptInKeys(str, Enum, metaclass=CaseInsensitiveEnumMeta): """MEMORY_STORES_V1_PREVIEW.""" TOOLSET_V1_PREVIEW = "Toolsets=V1Preview" """TOOLSET_V1_PREVIEW.""" + AGENT_ENDPOINT_V1_PREVIEW = "AgentEndpoints=V1Preview" + """AGENT_ENDPOINT_V1_PREVIEW.""" class FunctionShellToolParamEnvironmentType(str, Enum, metaclass=CaseInsensitiveEnumMeta): diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/models/_models.py b/sdk/ai/azure-ai-projects/azure/ai/projects/models/_models.py index f009264826d7..2974e60e06a3 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/models/_models.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/models/_models.py @@ -2792,7 +2792,7 @@ class ContainerNetworkPolicyAllowlistParam(ContainerNetworkPolicyParam, discrimi allowed_domains: list[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) """A list of allowed domains when type is ``allowlist``. Required.""" domain_secrets: Optional[list["_models.ContainerNetworkPolicyDomainSecretParam"]] = rest_field( - visibility=["read", "create", "update", "delete", "query"] + visibility=["create"] ) """Optional domain-scoped secrets for allowlisted domains.""" diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_operations.py b/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_operations.py index 5a9b2fc628c9..55f80af3bb5c 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_operations.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_operations.py @@ -43,6 +43,7 @@ [ _AgentDefinitionOptInKeys.HOSTED_AGENTS_V1_PREVIEW.value, _AgentDefinitionOptInKeys.WORKFLOW_AGENTS_V1_PREVIEW.value, + _FoundryFeaturesOptInKeys.AGENT_ENDPOINT_V1_PREVIEW.value, ] ) @@ -142,7 +143,12 @@ def build_agents_list_request( def build_agents_create_version_request( - agent_name: str, *, foundry_features: Optional[Union[str, _AgentDefinitionOptInKeys]] = None, **kwargs: Any + agent_name: str, + *, + foundry_features: Optional[ + Union[str, _AgentDefinitionOptInKeys, Literal[_FoundryFeaturesOptInKeys.AGENT_ENDPOINT_V1_PREVIEW]] + ] = None, + **kwargs: Any ) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) @@ -2348,6 +2354,7 @@ def create_version( :raises ~azure.core.exceptions.HttpResponseError: """ _foundry_features: Optional[str] = _get_agent_definition_opt_in_keys if self._config.allow_preview else None # type: ignore + error_map: MutableMapping = { 401: ClientAuthenticationError, 404: ResourceNotFoundError, diff --git a/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch.py b/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch.py index bc78f4d6baf8..de30f901a967 100644 --- a/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch.py +++ b/sdk/ai/azure-ai-projects/azure/ai/projects/operations/_patch.py @@ -19,9 +19,10 @@ BetaEvaluationTaxonomiesOperations, BetaEvaluatorsOperations, BetaInsightsOperations, + BetaOperations as GeneratedBetaOperations, BetaRedTeamsOperations, BetaSchedulesOperations, - BetaOperations as GeneratedBetaOperations, + BetaToolsetsOperations, ) @@ -47,6 +48,8 @@ class BetaOperations(GeneratedBetaOperations): """:class:`~azure.ai.projects.operations.BetaRedTeamsOperations` operations""" schedules: BetaSchedulesOperations """:class:`~azure.ai.projects.operations.BetaSchedulesOperations` operations""" + toolsets: BetaToolsetsOperations + """:class:`~azure.ai.projects.operations.BetaToolsetsOperations` operations""" def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) @@ -57,15 +60,16 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: __all__: List[str] = [ "AgentsOperations", "BetaEvaluationTaxonomiesOperations", - "EvaluationRulesOperations", "BetaEvaluatorsOperations", "BetaInsightsOperations", "BetaMemoryStoresOperations", "BetaOperations", "BetaRedTeamsOperations", "BetaSchedulesOperations", + "BetaToolsetsOperations", "ConnectionsOperations", "DatasetsOperations", + "EvaluationRulesOperations", "TelemetryOperations", ] # Add all objects you want publicly available to users at this package level diff --git a/sdk/ai/azure-ai-projects/samples/agents/sample_agent_basic.py b/sdk/ai/azure-ai-projects/samples/agents/sample_agent_basic.py index 2f097665de87..64b3161d4af6 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/sample_agent_basic.py +++ b/sdk/ai/azure-ai-projects/samples/agents/sample_agent_basic.py @@ -6,7 +6,8 @@ """ DESCRIPTION: This sample demonstrates how to run basic Prompt Agent operations - using the synchronous AIProjectClient. + using the synchronous AIProjectClient. It uses Entra ID authentication to + connect to the Microsoft Foundry service. The OpenAI compatible Responses and Conversation calls in this sample are made using the OpenAI client from the `openai` package. See https://platform.openai.com/docs/api-reference diff --git a/sdk/ai/azure-ai-projects/samples/agents/sample_agent_basic_api_key_auth.py b/sdk/ai/azure-ai-projects/samples/agents/sample_agent_basic_api_key_auth.py new file mode 100644 index 000000000000..0b6fd228f13d --- /dev/null +++ b/sdk/ai/azure-ai-projects/samples/agents/sample_agent_basic_api_key_auth.py @@ -0,0 +1,84 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ + +""" +DESCRIPTION: + This sample demonstrates how to run basic Prompt Agent operations + using the synchronous AIProjectClient. It uses API key authentication to + connect to the Microsoft Foundry service. + + Note that API key authentication is available starting with version + 2.0.2 of the client library. + + The OpenAI compatible Responses and Conversation calls in this sample are made using + the OpenAI client from the `openai` package. See https://platform.openai.com/docs/api-reference + for more information. + +USAGE: + python sample_agent_basic_api_key_auth.py + + Before running the sample: + + pip install "azure-ai-projects>=2.0.2" python-dotenv + + Set these environment variables with your own values: + 2) FOUNDRY_PROJECT_API_KEY - The Project API key as found in your Foundry project home page. + 3) FOUNDRY_MODEL_NAME - The deployment name of the AI model, as found under the "Name" column in + the "Models + endpoints" tab in your Microsoft Foundry project. +""" + +import os +from dotenv import load_dotenv +from azure.core.credentials import AzureKeyCredential +from azure.ai.projects import AIProjectClient +from azure.ai.projects.models import PromptAgentDefinition + +load_dotenv() + +endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] +api_key = os.environ["FOUNDRY_PROJECT_API_KEY"] + +with ( + AIProjectClient(endpoint=endpoint, credential=AzureKeyCredential(api_key)) as project_client, + project_client.get_openai_client() as openai_client, +): + + agent = project_client.agents.create_version( + agent_name="MyAgent", + definition=PromptAgentDefinition( + model=os.environ["FOUNDRY_MODEL_NAME"], + instructions="You are a helpful assistant that answers general questions", + ), + ) + print(f"Agent created (id: {agent.id}, name: {agent.name}, version: {agent.version})") + + conversation = openai_client.conversations.create( + items=[{"type": "message", "role": "user", "content": "What is the size of France in square miles?"}], + ) + print(f"Created conversation with initial user message (id: {conversation.id})") + + response = openai_client.responses.create( + conversation=conversation.id, + extra_body={"agent_reference": {"name": agent.name, "type": "agent_reference"}}, + ) + print(f"Response output: {response.output_text}") + + openai_client.conversations.items.create( + conversation_id=conversation.id, + items=[{"type": "message", "role": "user", "content": "And what is the capital city?"}], + ) + print("Added a second user message to the conversation") + + response = openai_client.responses.create( + conversation=conversation.id, + extra_body={"agent_reference": {"name": agent.name, "type": "agent_reference"}}, + ) + print(f"Response output: {response.output_text}") + + openai_client.conversations.delete(conversation_id=conversation.id) + print("Conversation deleted") + + project_client.agents.delete_version(agent_name=agent.name, agent_version=agent.version) + print("Agent deleted") diff --git a/sdk/ai/azure-ai-projects/samples/agents/sample_agent_basic_api_key_auth_async.py b/sdk/ai/azure-ai-projects/samples/agents/sample_agent_basic_api_key_auth_async.py new file mode 100644 index 000000000000..bdf7ea738457 --- /dev/null +++ b/sdk/ai/azure-ai-projects/samples/agents/sample_agent_basic_api_key_auth_async.py @@ -0,0 +1,91 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ + +""" +DESCRIPTION: + This sample demonstrates how to run basic Prompt Agent operations + using the asynchronous AIProjectClient. It uses API key authentication to + connect to the Microsoft Foundry service. + + Note that API key authentication is available starting with version + 2.0.2 of the client library. + + The OpenAI compatible Responses and Conversation calls in this sample are made using + the OpenAI client from the `openai` package. See https://platform.openai.com/docs/api-reference + for more information. + +USAGE: + python sample_agent_basic_async.py + + Before running the sample: + + pip install "azure-ai-projects>=2.0.2" python-dotenv aiohttp + + Set these environment variables with your own values: + 2) FOUNDRY_PROJECT_API_KEY - The Project API key as found in your Foundry project home page. + 3) FOUNDRY_MODEL_NAME - The deployment name of the AI model, as found under the "Name" column in + the "Models + endpoints" tab in your Microsoft Foundry project. +""" + +import asyncio +import os +from dotenv import load_dotenv +from azure.core.credentials import AzureKeyCredential +from azure.ai.projects.aio import AIProjectClient +from azure.ai.projects.models import PromptAgentDefinition + +load_dotenv() + +endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] +api_key = os.environ["FOUNDRY_PROJECT_API_KEY"] + + +async def main() -> None: + async with ( + AIProjectClient(endpoint=endpoint, credential=AzureKeyCredential(api_key)) as project_client, + project_client.get_openai_client() as openai_client, + ): + + agent = await project_client.agents.create_version( + agent_name="MyAgent", + definition=PromptAgentDefinition( + model=os.environ["FOUNDRY_MODEL_NAME"], + instructions="You are a helpful assistant that answers general questions.", + ), + ) + print(f"Agent created (name: {agent.name}, id: {agent.id}, version: {agent.version})") + + conversation = await openai_client.conversations.create( + items=[{"type": "message", "role": "user", "content": "What is the size of France in square miles?"}], + ) + print(f"Created conversation with initial user message (id: {conversation.id})") + + response = await openai_client.responses.create( + conversation=conversation.id, + extra_body={"agent_reference": {"name": agent.name, "type": "agent_reference"}}, + ) + print(f"Response output: {response.output_text}") + + await openai_client.conversations.items.create( + conversation_id=conversation.id, + items=[{"type": "message", "role": "user", "content": "And what is the capital city?"}], + ) + print("Added a second user message to the conversation") + + response = await openai_client.responses.create( + conversation=conversation.id, + extra_body={"agent_reference": {"name": agent.name, "type": "agent_reference"}}, + ) + print(f"Response output: {response.output_text}") + + await openai_client.conversations.delete(conversation_id=conversation.id) + print("Conversation deleted") + + await project_client.agents.delete_version(agent_name=agent.name, agent_version=agent.version) + print("Agent deleted") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/ai/azure-ai-projects/samples/agents/sample_agent_basic_async.py b/sdk/ai/azure-ai-projects/samples/agents/sample_agent_basic_async.py index 0f9d39bc6685..3936a1aec2e6 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/sample_agent_basic_async.py +++ b/sdk/ai/azure-ai-projects/samples/agents/sample_agent_basic_async.py @@ -6,7 +6,8 @@ """ DESCRIPTION: This sample demonstrates how to run basic Prompt Agent operations - using the asynchronous AIProjectClient. + using the asynchronous AIProjectClient. It uses Entra ID authentication to + connect to the Microsoft Foundry service. The OpenAI compatible Responses and Conversation calls in this sample are made using the OpenAI client from the `openai` package. See https://platform.openai.com/docs/api-reference diff --git a/sdk/ai/azure-ai-projects/tests/conftest.py b/sdk/ai/azure-ai-projects/tests/conftest.py index 483f6fd7f4eb..a6dd76036377 100644 --- a/sdk/ai/azure-ai-projects/tests/conftest.py +++ b/sdk/ai/azure-ai-projects/tests/conftest.py @@ -48,6 +48,7 @@ class SanitizedValues: PROJECT_NAME = "sanitized-project-name" COMPONENT_NAME = "sanitized-component-name" AGENTS_API_VERSION = "sanitized-api-version" + API_KEY = "sanitized-api-key" @pytest.fixture(scope="session") @@ -59,6 +60,7 @@ def sanitized_values(): "account_name": f"{SanitizedValues.ACCOUNT_NAME}", "component_name": f"{SanitizedValues.COMPONENT_NAME}", "agents_api_version": f"{SanitizedValues.AGENTS_API_VERSION}", + "api_key": f"{SanitizedValues.API_KEY}", } @@ -153,6 +155,8 @@ def sanitize_url_paths(): ) add_body_string_sanitizer(target=image_generation_model, value="sanitized-gpt-image") + add_header_regex_sanitizer(key="api-key", value=SanitizedValues.API_KEY) + # Deterministic fallback sanitization for image generation deployment/model values. # These do not depend on environment variables and ensure recordings are redacted even # when runtime values come from unexpected sources. @@ -167,7 +171,7 @@ def sanitize_url_paths(): ) # Sanitize API key from service response (this includes Application Insights connection string) - add_body_key_sanitizer(json_path="credentials.key", value="sanitized-api-key") + add_body_key_sanitizer(json_path="credentials.key", value=SanitizedValues.API_KEY) # Sanitize GitHub personal access tokens that may appear in connection credentials add_general_regex_sanitizer(regex=r"github_pat_[A-Za-z0-9_]+", value="sanitized-github-pat") diff --git a/sdk/ai/azure-ai-projects/tests/foundry_features_header/foundry_features_header_test_base.py b/sdk/ai/azure-ai-projects/tests/foundry_features_header/foundry_features_header_test_base.py index 22cf12076aa6..efb53002ddf2 100644 --- a/sdk/ai/azure-ai-projects/tests/foundry_features_header/foundry_features_header_test_base.py +++ b/sdk/ai/azure-ai-projects/tests/foundry_features_header/foundry_features_header_test_base.py @@ -16,6 +16,7 @@ """ import inspect +import pytest from typing import Any, ClassVar, List, Tuple, Union, get_origin from azure.core.credentials import AccessToken @@ -41,11 +42,30 @@ "toolsets": "Toolsets=V1Preview", } +# Shared test cases for non-beta methods that optionally send the Foundry-Features header. +# Used by both test_foundry_features_header_optional.py (sync) and +# test_foundry_features_header_optional_async.py (async). +_NON_BETA_OPTIONAL_TEST_CASES = [ + # Each pytest.param entry has the following positional arguments: + # 1. method_name (str) – "." on AIProjectClient, e.g. "agents.create_version" + # The subclient and method names are parsed automatically from this string. + # 2. expected_header_value (str) – Expected value of the Foundry-Features header when allow_preview=True. + # Use a comma-separated list of feature=version pairs, e.g. "FeatureA=V1Preview,FeatureB=V1Preview". + # The test id is derived automatically from method_name. + pytest.param( + "agents.create_version", + "HostedAgents=V1Preview,WorkflowAgents=V1Preview,AgentEndpoints=V1Preview", + ), + pytest.param( + "evaluation_rules.create_or_update", + "Evaluations=V1Preview", + ), +] + # Both sentinel values – used by _make_fake_call to detect required parameters # whose defaults are the internal _Unset object (rather than inspect.Parameter.empty). _UNSET_SENTINELS: frozenset = frozenset({_SyncUnset, _AsyncUnset}) - # --------------------------------------------------------------------------- # Sentinel exception raised by capturing transports # --------------------------------------------------------------------------- @@ -147,9 +167,7 @@ def _make_fake_call(cls, method: Any) -> Any: if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD): continue - is_required = ( - param.default is inspect.Parameter.empty or param.default in _UNSET_SENTINELS - ) + is_required = param.default is inspect.Parameter.empty or param.default in _UNSET_SENTINELS if not is_required: continue @@ -176,8 +194,7 @@ def _record_header_assertion(cls, label: str, request: Any, expected_value: str) f"missing or empty.\nActual headers: {dict(request.headers)}" ) assert header_value == expected_value, ( - f"{label}: expected '{FOUNDRY_FEATURES_HEADER}: {expected_value}' " - f"but got '{header_value}'" + f"{label}: expected '{FOUNDRY_FEATURES_HEADER}: {expected_value}' " f"but got '{header_value}'" ) cls._report_max_label_len = max(cls._report_max_label_len, len(label)) cls._report.append((label, header_value)) @@ -192,6 +209,6 @@ def _record_header_absence_assertion(cls, label: str, request: Any) -> None: f"{label}: expected '{FOUNDRY_FEATURES_HEADER}' header to be absent.\n" f"Actual headers: {dict(request.headers)}" ) - absence_note = f'\'{FOUNDRY_FEATURES_HEADER}\' header not present (as expected)' + absence_note = f"'{FOUNDRY_FEATURES_HEADER}' header not present (as expected)" cls._report_absent_max_label_len = max(cls._report_absent_max_label_len, len(label)) cls._report_absent.append((label, absence_note)) diff --git a/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header.py b/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header.py index 71fdf4fd4212..b9890940a9da 100644 --- a/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header.py +++ b/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header.py @@ -45,7 +45,6 @@ _RequestCaptured, ) - # --------------------------------------------------------------------------- # Sync-specific transport # --------------------------------------------------------------------------- @@ -146,7 +145,7 @@ def _print_report() -> Iterator[None]: if report: print("\n\nFoundry-Features header report (sync):") for label, header_value in sorted(report): - print(f"{label:<{max_len}} | \"{header_value}\"") + print(f'{label:<{max_len}} | "{header_value}"') # --------------------------------------------------------------------------- @@ -209,4 +208,4 @@ def test_foundry_features_header( """Assert that *method_name* on .beta. sends the expected Foundry-Features value.""" sc = getattr(client.beta, subclient_name) method = getattr(sc, method_name) - self._assert_header(label, self._make_fake_call(method), expected_header_value) \ No newline at end of file + self._assert_header(label, self._make_fake_call(method), expected_header_value) diff --git a/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_async.py b/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_async.py index 73db8c64491d..9cadf09a6910 100644 --- a/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_async.py +++ b/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_async.py @@ -47,7 +47,6 @@ _RequestCaptured, ) - # --------------------------------------------------------------------------- # Async-specific transport # --------------------------------------------------------------------------- @@ -156,7 +155,7 @@ def _print_report_async() -> Iterator[None]: if report: print("\n\nFoundry-Features header report (async):") for label, header_value in sorted(report): - print(f"{label:<{max_len}} | \"{header_value}\"") + print(f'{label:<{max_len}} | "{header_value}"') # --------------------------------------------------------------------------- diff --git a/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_optional.py b/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_optional.py index ef32e04421f7..b4d51f49842c 100644 --- a/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_optional.py +++ b/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_optional.py @@ -21,28 +21,11 @@ FAKE_ENDPOINT, FakeCredential, FoundryFeaturesHeaderTestBase, + _NON_BETA_OPTIONAL_TEST_CASES, _RequestCaptured, ) -_NON_BETA_OPTIONAL_TEST_CASES = [ - # Each pytest.param entry has the following positional arguments: - # 1. method_name (str) – "." on AIProjectClient, e.g. "agents.create_version" - # The subclient and method names are parsed automatically from this string. - # 2. expected_header_value (str) – Expected value of the Foundry-Features header when allow_preview=True. - # Use a comma-separated list of feature=version pairs, e.g. "FeatureA=V1Preview,FeatureB=V1Preview". - # The test id is derived automatically from method_name. - pytest.param( - "agents.create_version", - "HostedAgents=V1Preview,WorkflowAgents=V1Preview", - ), - pytest.param( - "evaluation_rules.create_or_update", - "Evaluations=V1Preview", - ), -] - - class CapturingTransport(HttpTransport): """Sync transport that captures the outgoing request and raises _RequestCaptured.""" @@ -94,14 +77,16 @@ def _print_report_optional() -> Iterator[None]: max_len = TestFoundryFeaturesHeaderOptional._report_max_label_len print("\n\nFoundry-Features optional header report (sync) — test_optional_header_present_when_preview_enabled:") for label, header_value in sorted(present_report): - print(f"{label:<{max_len}} | \"{header_value}\"") + print(f'{label:<{max_len}} | "{header_value}"') absent_report = TestFoundryFeaturesHeaderOptional._report_absent if absent_report: max_len = TestFoundryFeaturesHeaderOptional._report_absent_max_label_len - print("\n\nFoundry-Features optional header report (sync) — test_optional_header_absent_when_preview_not_enabled:") + print( + "\n\nFoundry-Features optional header report (sync) — test_optional_header_absent_when_preview_not_enabled:" + ) for label, header_value in sorted(absent_report): - print(f"{label:<{max_len}} | \"{header_value}\"") + print(f'{label:<{max_len}} | "{header_value}"') class TestFoundryFeaturesHeaderOptional(FoundryFeaturesHeaderTestBase): diff --git a/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_optional_async.py b/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_optional_async.py index 06e2c21637f0..711af5221896 100644 --- a/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_optional_async.py +++ b/sdk/ai/azure-ai-projects/tests/foundry_features_header/test_foundry_features_header_optional_async.py @@ -22,28 +22,11 @@ FAKE_ENDPOINT, AsyncFakeCredential, FoundryFeaturesHeaderTestBase, + _NON_BETA_OPTIONAL_TEST_CASES, _RequestCaptured, ) -_NON_BETA_OPTIONAL_ASYNC_TEST_CASES = [ - # Each pytest.param entry has the following positional arguments: - # 1. method_name (str) – "." on AIProjectClient, e.g. "agents.create_version" - # The subclient and method names are parsed automatically from this string. - # 2. expected_header_value (str) – Expected value of the Foundry-Features header when allow_preview=True. - # Use a comma-separated list of feature=version pairs, e.g. "FeatureA=V1Preview,FeatureB=V1Preview". - # The test id is derived automatically from method_name. - pytest.param( - "agents.create_version", - "HostedAgents=V1Preview,WorkflowAgents=V1Preview", - ), - pytest.param( - "evaluation_rules.create_or_update", - "Evaluations=V1Preview", - ), -] - - class CapturingAsyncTransport(AsyncHttpTransport): """Async transport that captures the outgoing request and raises _RequestCaptured.""" @@ -91,16 +74,20 @@ def _print_report_optional_async() -> Iterator[None]: present_report = TestFoundryFeaturesHeaderOptionalAsync._report if present_report: max_len = TestFoundryFeaturesHeaderOptionalAsync._report_max_label_len - print("\n\nFoundry-Features optional header report (async) — test_optional_header_present_when_preview_enabled_async:") + print( + "\n\nFoundry-Features optional header report (async) — test_optional_header_present_when_preview_enabled_async:" + ) for label, header_value in sorted(present_report): - print(f"{label:<{max_len}} | \"{header_value}\"") + print(f'{label:<{max_len}} | "{header_value}"') absent_report = TestFoundryFeaturesHeaderOptionalAsync._report_absent if absent_report: max_len = TestFoundryFeaturesHeaderOptionalAsync._report_absent_max_label_len - print("\n\nFoundry-Features optional header report (async) — test_optional_header_absent_when_preview_not_enabled_async:") + print( + "\n\nFoundry-Features optional header report (async) — test_optional_header_absent_when_preview_not_enabled_async:" + ) for label, header_value in sorted(absent_report): - print(f"{label:<{max_len}} | \"{header_value}\"") + print(f'{label:<{max_len}} | "{header_value}"') class TestFoundryFeaturesHeaderOptionalAsync(FoundryFeaturesHeaderTestBase): @@ -144,9 +131,7 @@ async def _assert_header_absent_async(cls, label: str, call: Any) -> None: cls._record_header_absence_assertion(label, request) @pytest.mark.asyncio - @pytest.mark.parametrize( - "method_name,expected_header_value", _NON_BETA_OPTIONAL_ASYNC_TEST_CASES - ) + @pytest.mark.parametrize("method_name,expected_header_value", _NON_BETA_OPTIONAL_TEST_CASES) async def test_optional_header_present_when_preview_enabled_async( self, async_client_preview_enabled: AsyncAIProjectClient, @@ -159,9 +144,7 @@ async def test_optional_header_present_when_preview_enabled_async( await self._assert_header_present_async(method_name, self._make_fake_call(method), expected_header_value) @pytest.mark.asyncio - @pytest.mark.parametrize( - "method_name,_expected_header_value", _NON_BETA_OPTIONAL_ASYNC_TEST_CASES - ) + @pytest.mark.parametrize("method_name,_expected_header_value", _NON_BETA_OPTIONAL_TEST_CASES) async def test_optional_header_absent_when_preview_not_enabled_async( self, async_client_preview_disabled: AsyncAIProjectClient, diff --git a/sdk/ai/azure-ai-projects/tests/test_base.py b/sdk/ai/azure-ai-projects/tests/test_base.py index af3d3e3f30a3..bf114404b34d 100644 --- a/sdk/ai/azure-ai-projects/tests/test_base.py +++ b/sdk/ai/azure-ai-projects/tests/test_base.py @@ -40,6 +40,7 @@ EnvironmentVariableLoader, "", foundry_project_endpoint="https://sanitized-account-name.services.ai.azure.com/api/projects/sanitized-project-name", + foundry_project_api_key="sanitized-api-key", foundry_model_name="sanitized-model-deployment-name", image_generation_model_deployment_name="sanitized-gpt-image", bing_project_connection_id="/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sanitized-resource-group/providers/Microsoft.CognitiveServices/accounts/sanitized-account/projects/sanitized-project/connections/sanitized-bing-connection",