Files
semantic-kernel/python/semantic_kernel/agents/open_ai/azure_responses_agent.py
T
Dmytro Struk 2a47ba9b7d Python: Removed usage of DefaultAzureCredential (#12964)
### Motivation and Context

<!-- Thank you for your contribution to the semantic-kernel repo!
Please help reviewers and future users, providing the following
information:
  1. Why is this change required?
  2. What problem does it solve?
  3. What scenario does it contribute to?
  4. If it fixes an open issue, please link to the issue here.
-->

The usage of `DefaultAzureCredential` by default is unsafe and raises
security issues. Instead, users should provide their own credential
types such as `AzureCliCredential`, `ManagedIdentityCredential` and so
on. More information here:

https://learn.microsoft.com/en-us/python/api/azure-identity/azure.identity.aio?view=azure-python

In this PR:
1. Removed the usage of `DefaultAzureCredential` from authentication
logic.
2. Replaced `DefaultAzureCredential` with `AzureCliCredential` in all
examples.

**Note**: this is breaking change for Azure users, who relies on
`DefaultAzureCredential`. Users will need to update the code and pass
the credential type of their choice by using `credential` parameter when
initializing one of the Azure clients.

Before:
```python
chat_completion = AzureChatCompletion()
```

After:
```python
chat_completion = AzureChatCompletion(credential=AzureCliCredential())
```

### Contribution Checklist

<!-- Before submitting this PR, please make sure: -->

- [x] The code builds clean without any errors or warnings
- [x] The PR follows the [SK Contribution
Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md)
and the [pre-submission formatting
script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts)
raises no violations
- [x] All unit tests pass, and I have added new tests where possible
- [ ] I didn't break anyone 😄
2025-08-25 18:29:13 +00:00

294 lines
12 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
import logging
import sys
from collections.abc import Awaitable, Callable
from copy import copy
from typing import TYPE_CHECKING, Any
from azure.core.credentials import TokenCredential
from openai import AsyncAzureOpenAI
from pydantic import ValidationError
from semantic_kernel.agents import OpenAIResponsesAgent
from semantic_kernel.agents.agent import register_agent_type
from semantic_kernel.connectors.ai.open_ai.settings.azure_open_ai_settings import AzureOpenAISettings
from semantic_kernel.exceptions.agent_exceptions import (
AgentInitializationException,
)
from semantic_kernel.utils.authentication.entra_id_authentication import get_entra_auth_token
from semantic_kernel.utils.feature_stage_decorator import experimental
from semantic_kernel.utils.telemetry.user_agent import APP_INFO, prepend_semantic_kernel_to_user_agent
if TYPE_CHECKING:
from semantic_kernel.kernel_pydantic import KernelBaseSettings
logger: logging.Logger = logging.getLogger(__name__)
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
if sys.version < "3.11":
from typing_extensions import Self # pragma: no cover
else:
from typing import Self # type: ignore # pragma: no cover
if sys.version_info >= (3, 13):
from warnings import deprecated
else:
from typing_extensions import deprecated
@experimental
@register_agent_type("azure_responses")
class AzureResponsesAgent(OpenAIResponsesAgent):
"""Azure Responses Agent class.
Provides the ability to interact with Azure's Responses API.
"""
@staticmethod
@deprecated(
"setup_resources is deprecated. Use AzureResponsesAgent.create_client() instead. This method will be removed by 2025-06-15." # noqa: E501
)
def setup_resources(
*,
ad_token: str | None = None,
ad_token_provider: Callable[[], str | Awaitable[str]] | None = None,
api_key: str | None = None,
api_version: str | None = None,
base_url: str | None = None,
default_headers: dict[str, str] | None = None,
deployment_name: str | None = None,
endpoint: str | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
token_scope: str | None = None,
credential: TokenCredential | None = None,
**kwargs: Any,
) -> tuple[AsyncAzureOpenAI, str]:
"""A method to create the Azure OpenAI client and the deployment name/model from the provided arguments.
Any arguments provided will override the values in the environment variables/environment file.
Args:
ad_token: The Microsoft Entra (previously Azure AD) token represented as a string
ad_token_provider: The Microsoft Entra (previously Azure AD) token provider provided as a callback
api_key: The API key
api_version: The API version
base_url: The base URL in the form https://<resource>.azure.openai.com/openai/deployments/<deployment_name>
default_headers: The default headers to add to the client
deployment_name: The Responses deployment name
endpoint: The endpoint in the form https://<resource>.azure.openai.com
env_file_path: The environment file path
env_file_encoding: The environment file encoding, defaults to utf-8
token_scope: The token scope
credential: The credential to use for authentication.
kwargs: Additional keyword arguments
Returns:
An Azure OpenAI client instance and the configured deployment name (model)
"""
try:
azure_openai_settings = AzureOpenAISettings(
api_key=api_key,
base_url=base_url,
endpoint=endpoint,
responses_deployment_name=deployment_name,
api_version=api_version,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
token_endpoint=token_scope,
)
except ValidationError as exc:
raise AgentInitializationException(f"Failed to create Azure OpenAI settings: {exc}") from exc
if (
azure_openai_settings.api_key is None
and ad_token_provider is None
and ad_token is None
and azure_openai_settings.token_endpoint
and credential
):
ad_token = get_entra_auth_token(credential, azure_openai_settings.token_endpoint)
# If we still have no credentials, we can't proceed
if not azure_openai_settings.api_key and not ad_token and not ad_token_provider and not credential:
raise AgentInitializationException(
"Please provide either an api_key, ad_token, ad_token_provider or credential for authentication."
)
merged_headers = dict(copy(default_headers)) if default_headers else {}
if default_headers:
merged_headers.update(default_headers)
if APP_INFO:
merged_headers.update(APP_INFO)
merged_headers = prepend_semantic_kernel_to_user_agent(merged_headers)
if not azure_openai_settings.endpoint:
raise AgentInitializationException("Please provide an Azure OpenAI endpoint")
if not azure_openai_settings.responses_deployment_name:
raise AgentInitializationException("Please provide an Azure OpenAI Responses deployment name")
client = AsyncAzureOpenAI(
azure_endpoint=str(azure_openai_settings.endpoint),
api_version=azure_openai_settings.api_version,
api_key=azure_openai_settings.api_key.get_secret_value() if azure_openai_settings.api_key else None,
azure_ad_token=ad_token,
azure_ad_token_provider=ad_token_provider,
default_headers=merged_headers,
**kwargs,
)
return client, azure_openai_settings.responses_deployment_name
@staticmethod
def create_client(
*,
ad_token: str | None = None,
ad_token_provider: Callable[[], str | Awaitable[str]] | None = None,
api_key: str | None = None,
api_version: str | None = None,
base_url: str | None = None,
default_headers: dict[str, str] | None = None,
deployment_name: str | None = None,
endpoint: str | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
token_scope: str | None = None,
credential: TokenCredential | None = None,
**kwargs: Any,
) -> AsyncAzureOpenAI:
"""A method to create the Azure OpenAI client.
Any arguments provided will override the values in the environment variables/environment file.
Args:
ad_token: The Microsoft Entra (previously Azure AD) token represented as a string
ad_token_provider: The Microsoft Entra (previously Azure AD) token provider provided as a callback
api_key: The API key
api_version: The API version
base_url: The base URL in the form https://<resource>.azure.openai.com/openai/deployments/<deployment_name>
default_headers: The default headers to add to the client
deployment_name: The Responses deployment name
endpoint: The endpoint in the form https://<resource>.azure.openai.com
env_file_path: The environment file path
env_file_encoding: The environment file encoding, defaults to utf-8
token_scope: The token scope
credential: The credential to use for authentication.
kwargs: Additional keyword arguments
Returns:
An Azure OpenAI client instance.
"""
try:
azure_openai_settings = AzureOpenAISettings(
api_key=api_key,
base_url=base_url,
endpoint=endpoint,
responses_deployment_name=deployment_name,
api_version=api_version,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
token_endpoint=token_scope,
)
except ValidationError as exc:
raise AgentInitializationException(f"Failed to create Azure OpenAI settings: {exc}") from exc
if (
azure_openai_settings.api_key is None
and ad_token_provider is None
and ad_token is None
and azure_openai_settings.token_endpoint
and credential
):
ad_token = get_entra_auth_token(credential, azure_openai_settings.token_endpoint)
# If we still have no credentials, we can't proceed
if not azure_openai_settings.api_key and not ad_token and not ad_token_provider and not credential:
raise AgentInitializationException(
"Please provide either an api_key, ad_token, ad_token_provider or credential for authentication."
)
merged_headers = dict(copy(default_headers)) if default_headers else {}
if default_headers:
merged_headers.update(default_headers)
if APP_INFO:
merged_headers.update(APP_INFO)
merged_headers = prepend_semantic_kernel_to_user_agent(merged_headers)
if not azure_openai_settings.endpoint:
raise AgentInitializationException("Please provide an Azure OpenAI endpoint")
if not azure_openai_settings.responses_deployment_name:
raise AgentInitializationException("Please provide an Azure OpenAI Responses deployment name")
return AsyncAzureOpenAI(
azure_endpoint=str(azure_openai_settings.endpoint),
api_version=azure_openai_settings.api_version,
api_key=azure_openai_settings.api_key.get_secret_value() if azure_openai_settings.api_key else None,
azure_ad_token=ad_token,
azure_ad_token_provider=ad_token_provider,
default_headers=merged_headers,
**kwargs,
)
@override
@classmethod
def resolve_placeholders(
cls: type[Self],
yaml_str: str,
settings: "KernelBaseSettings | None" = None,
extras: dict[str, Any] | None = None,
) -> str:
"""Substitute ${AzureOpenAI:Key} placeholders with fields from AzureOpenAIAgentSettings and extras."""
import re
pattern = re.compile(r"\$\{([^}]+)\}")
# Build the mapping only if settings is provided and valid
field_mapping: dict[str, Any] = {}
if settings is None:
settings = AzureOpenAISettings()
if not isinstance(settings, AzureOpenAISettings):
raise AgentInitializationException(f"Expected AzureOpenAISettings, got {type(settings).__name__}")
field_mapping.update({
"ChatModelId": getattr(settings, "responses_deployment_name", None),
"AgentId": getattr(settings, "agent_id", None),
"ApiKey": getattr(settings, "api_key", None),
"ApiVersion": getattr(settings, "api_version", None),
"BaseUrl": getattr(settings, "base_url", None),
"Endpoint": getattr(settings, "endpoint", None),
"TokenEndpoint": getattr(settings, "token_endpoint", None),
})
if extras:
field_mapping.update(extras)
def replacer(match: re.Match[str]) -> str:
"""Replace the matched placeholder with the corresponding value from field_mapping."""
full_key = match.group(1) # for example, AzureOpenAI:ApiKey
section, _, key = full_key.partition(":")
if section != "AzureOpenAI":
return match.group(0)
# Try short key first (ApiKey), then full (AzureOpenAI:ApiKey)
return str(field_mapping.get(key) or field_mapping.get(full_key) or match.group(0))
result = pattern.sub(replacer, yaml_str)
# Safety check for unresolved placeholders
unresolved = pattern.findall(result)
if unresolved:
raise AgentInitializationException(
f"Unresolved placeholders in spec: {', '.join(f'${{{key}}}' for key in unresolved)}"
)
return result