Skip to content
Open
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
1 change: 1 addition & 0 deletions sdk/ai/azure-ai-projects/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ AZURE_AI_PROJECTS_CONSOLE_LOGGING=
# Project endpoint has the format:
# `https://<your-ai-services-account-name>.services.ai.azure.com/api/projects/<your-project-name>`
FOUNDRY_PROJECT_ENDPOINT=
FOUNDRY_PROJECT_API_KEY=
FOUNDRY_MODEL_NAME=
FOUNDRY_AGENT_NAME=
CONVERSATION_ID=
Expand Down
52 changes: 47 additions & 5 deletions sdk/ai/azure-ai-projects/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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:

Expand All @@ -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,
):
```

Expand All @@ -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,
):
```

Expand Down
2 changes: 1 addition & 1 deletion sdk/ai/azure-ai-projects/assets.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
15 changes: 11 additions & 4 deletions sdk/ai/azure-ai-projects/azure/ai/projects/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
26 changes: 19 additions & 7 deletions sdk/ai/azure-ai-projects/azure/ai/projects/_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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")

Expand All @@ -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)
Expand All @@ -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)
51 changes: 36 additions & 15 deletions sdk/ai/azure-ai-projects/azure/ai/projects/_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`).
Expand All @@ -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 = (
Expand All @@ -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)
Expand All @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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 <REDACTED>\2", rendered)
redacted = self._API_KEY_HEADER_DICT_PATTERN.sub(r"\1<REDACTED>\2", redacted)
if redacted != rendered:
# Replace the pre-formatted content so handlers emit sanitized output.
record.msg = redacted
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions sdk/ai/azure-ai-projects/azure/ai/projects/aio/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
Loading
Loading