mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Unify Azure credential handling across all Python packages (#4088)
Replace ad_token, ad_token_provider, and get_entra_auth_token with a unified credential parameter across all Azure-related packages. Core changes: - Add AzureCredentialTypes (TokenCredential | AsyncTokenCredential) and AzureTokenProvider (Callable[[], str | Awaitable[str]]) type aliases - Add resolve_credential_to_token_provider() using azure.identity's get_bearer_token_provider for automatic token caching/refresh - Update AzureOpenAIChatClient, AzureOpenAIResponsesClient, and AzureOpenAIAssistantsClient to accept credential: AzureCredentialTypes | AzureTokenProvider - Remove ad_token, ad_token_provider params and get_entra_auth_token helpers Package updates: - azure-ai: Accept AzureCredentialTypes on AzureAIClient, AzureAIAgentClient, AzureAIProjectAgentProvider, AzureAIAgentsProvider - azure-ai-search: Accept AzureCredentialTypes on AzureAISearchContextProvider - purview: Accept AzureCredentialTypes | AzureTokenProvider on PurviewClient, PurviewPolicyMiddleware, PurviewChatPolicyMiddleware Fixes #3449 Fixes #3500 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
4c8f595019
commit
fd4e6e816c
+5
-3
@@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict
|
||||
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message
|
||||
from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext
|
||||
from agent_framework._settings import SecretString, load_settings
|
||||
from agent_framework.azure._entra_id_authentication import AzureCredentialTypes
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
@@ -148,7 +149,7 @@ class AzureAISearchContextProvider(BaseContextProvider):
|
||||
endpoint: str | None = None,
|
||||
index_name: str | None = None,
|
||||
api_key: str | AzureKeyCredential | None = None,
|
||||
credential: AsyncTokenCredential | None = None,
|
||||
credential: AzureCredentialTypes | None = None,
|
||||
*,
|
||||
mode: Literal["semantic", "agentic"] = "semantic",
|
||||
top_k: int = 5,
|
||||
@@ -175,7 +176,8 @@ class AzureAISearchContextProvider(BaseContextProvider):
|
||||
endpoint: Azure AI Search endpoint URL.
|
||||
index_name: Name of the search index to query.
|
||||
api_key: API key for authentication.
|
||||
credential: AsyncTokenCredential for managed identity authentication.
|
||||
credential: Azure credential for managed identity authentication.
|
||||
Accepts a TokenCredential, AsyncTokenCredential, or a callable token provider.
|
||||
mode: Search mode - "semantic" or "agentic". Default: "semantic".
|
||||
top_k: Maximum number of documents to retrieve. Default: 5.
|
||||
semantic_configuration_name: Name of semantic configuration in the index.
|
||||
@@ -223,7 +225,7 @@ class AzureAISearchContextProvider(BaseContextProvider):
|
||||
|
||||
resolved_credential: AzureKeyCredential | AsyncTokenCredential
|
||||
if credential:
|
||||
resolved_credential = credential
|
||||
resolved_credential = credential # type: ignore[assignment]
|
||||
elif isinstance(api_key, AzureKeyCredential):
|
||||
resolved_credential = api_key
|
||||
elif settings.get("api_key"):
|
||||
|
||||
@@ -17,11 +17,11 @@ from agent_framework import (
|
||||
from agent_framework._mcp import MCPTool
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework._tools import ToolTypes
|
||||
from agent_framework.azure._entra_id_authentication import AzureCredentialTypes
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from azure.ai.agents.aio import AgentsClient
|
||||
from azure.ai.agents.models import Agent as AzureAgent
|
||||
from azure.ai.agents.models import ResponseFormatJsonSchema, ResponseFormatJsonSchemaType
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions
|
||||
@@ -93,7 +93,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
|
||||
agents_client: AgentsClient | None = None,
|
||||
*,
|
||||
project_endpoint: str | None = None,
|
||||
credential: AsyncTokenCredential | None = None,
|
||||
credential: AzureCredentialTypes | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
) -> None:
|
||||
@@ -106,7 +106,8 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
|
||||
Keyword Args:
|
||||
project_endpoint: The Azure AI Project endpoint URL.
|
||||
Can also be set via AZURE_AI_PROJECT_ENDPOINT environment variable.
|
||||
credential: Azure async credential for authentication.
|
||||
credential: Azure credential for authentication. Accepts a TokenCredential,
|
||||
AsyncTokenCredential, or a callable token provider.
|
||||
Required if agents_client is not provided.
|
||||
env_file_path: Path to .env file for loading settings.
|
||||
env_file_encoding: Encoding of the .env file.
|
||||
@@ -137,7 +138,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]):
|
||||
raise ServiceInitializationError("Azure credential is required when agents_client is not provided.")
|
||||
self._agents_client = AgentsClient(
|
||||
endpoint=resolved_endpoint,
|
||||
credential=credential,
|
||||
credential=credential, # type: ignore[arg-type]
|
||||
user_agent=AGENT_FRAMEWORK_USER_AGENT,
|
||||
)
|
||||
self._should_close_client = True
|
||||
|
||||
@@ -35,6 +35,7 @@ from agent_framework import (
|
||||
)
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework._tools import ToolTypes
|
||||
from agent_framework.azure._entra_id_authentication import AzureCredentialTypes
|
||||
from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidRequestError, ServiceResponseException
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
from azure.ai.agents.aio import AgentsClient
|
||||
@@ -84,7 +85,6 @@ from azure.ai.agents.models import (
|
||||
ToolDefinition,
|
||||
ToolOutput,
|
||||
)
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._shared import AzureAISettings, to_azure_ai_agent_tools
|
||||
@@ -415,7 +415,7 @@ class AzureAIAgentClient(
|
||||
thread_id: str | None = None,
|
||||
project_endpoint: str | None = None,
|
||||
model_deployment_name: str | None = None,
|
||||
credential: AsyncTokenCredential | None = None,
|
||||
credential: AzureCredentialTypes | None = None,
|
||||
should_cleanup_agent: bool = True,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
|
||||
@@ -439,7 +439,8 @@ class AzureAIAgentClient(
|
||||
Ignored when a agents_client is passed.
|
||||
model_deployment_name: The model deployment name to use for agent creation.
|
||||
Can also be set via environment variable AZURE_AI_MODEL_DEPLOYMENT_NAME.
|
||||
credential: Azure async credential to use for authentication.
|
||||
credential: Azure credential for authentication. Accepts a TokenCredential,
|
||||
AsyncTokenCredential, or a callable token provider.
|
||||
should_cleanup_agent: Whether to cleanup (delete) agents created by this client when
|
||||
the client is closed or context is exited. Defaults to True. Only affects agents
|
||||
created by this client instance; existing agents passed via agent_id are never deleted.
|
||||
@@ -513,7 +514,7 @@ class AzureAIAgentClient(
|
||||
raise ServiceInitializationError("Azure credential is required when agents_client is not provided.")
|
||||
agents_client = AgentsClient(
|
||||
endpoint=resolved_endpoint,
|
||||
credential=credential,
|
||||
credential=credential, # type: ignore[arg-type]
|
||||
user_agent=AGENT_FRAMEWORK_USER_AGENT,
|
||||
)
|
||||
should_close_client = True
|
||||
|
||||
@@ -23,6 +23,7 @@ from agent_framework import (
|
||||
)
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework._tools import ToolTypes
|
||||
from agent_framework.azure._entra_id_authentication import AzureCredentialTypes
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
from agent_framework.openai import OpenAIResponsesOptions
|
||||
@@ -41,7 +42,6 @@ from azure.ai.projects.models import (
|
||||
WebSearchPreviewTool,
|
||||
)
|
||||
from azure.ai.projects.models import FileSearchTool as ProjectsFileSearchTool
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
|
||||
from ._shared import AzureAISettings, create_text_format_config
|
||||
@@ -109,7 +109,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
conversation_id: str | None = None,
|
||||
project_endpoint: str | None = None,
|
||||
model_deployment_name: str | None = None,
|
||||
credential: AsyncTokenCredential | None = None,
|
||||
credential: AzureCredentialTypes | None = None,
|
||||
use_latest_version: bool | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
@@ -132,7 +132,8 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
Ignored when a project_client is passed.
|
||||
model_deployment_name: The model deployment name to use for agent creation.
|
||||
Can also be set via environment variable AZURE_AI_MODEL_DEPLOYMENT_NAME.
|
||||
credential: Azure async credential to use for authentication.
|
||||
credential: Azure credential for authentication. Accepts a TokenCredential,
|
||||
AsyncTokenCredential, or a callable token provider.
|
||||
use_latest_version: Boolean flag that indicates whether to use latest agent version
|
||||
if it exists in the service.
|
||||
env_file_path: Path to environment file for loading settings.
|
||||
@@ -197,7 +198,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
raise ServiceInitializationError("Azure credential is required when project_client is not provided.")
|
||||
project_client = AIProjectClient(
|
||||
endpoint=resolved_endpoint,
|
||||
credential=credential,
|
||||
credential=credential, # type: ignore[arg-type]
|
||||
user_agent=AGENT_FRAMEWORK_USER_AGENT,
|
||||
)
|
||||
should_close_client = True
|
||||
@@ -950,7 +951,7 @@ class AzureAIClient(
|
||||
conversation_id: str | None = None,
|
||||
project_endpoint: str | None = None,
|
||||
model_deployment_name: str | None = None,
|
||||
credential: AsyncTokenCredential | None = None,
|
||||
credential: AzureCredentialTypes | None = None,
|
||||
use_latest_version: bool | None = None,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
|
||||
@@ -972,7 +973,8 @@ class AzureAIClient(
|
||||
Ignored when a project_client is passed.
|
||||
model_deployment_name: The model deployment name to use for agent creation.
|
||||
Can also be set via environment variable AZURE_AI_MODEL_DEPLOYMENT_NAME.
|
||||
credential: Azure async credential to use for authentication.
|
||||
credential: Azure credential for authentication. Accepts a TokenCredential
|
||||
or AsyncTokenCredential.
|
||||
use_latest_version: Boolean flag that indicates whether to use latest agent version
|
||||
if it exists in the service.
|
||||
middleware: Optional sequence of chat middlewares to include.
|
||||
|
||||
@@ -18,6 +18,7 @@ from agent_framework import (
|
||||
from agent_framework._mcp import MCPTool
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework._tools import ToolTypes
|
||||
from agent_framework.azure._entra_id_authentication import AzureCredentialTypes
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.ai.projects.models import (
|
||||
@@ -29,7 +30,6 @@ from azure.ai.projects.models import (
|
||||
from azure.ai.projects.models import (
|
||||
FunctionTool as AzureFunctionTool,
|
||||
)
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
from ._client import AzureAIClient, AzureAIProjectAgentOptions
|
||||
from ._shared import AzureAISettings, create_text_format_config, from_azure_ai_tools, to_azure_ai_tools
|
||||
@@ -103,7 +103,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
|
||||
*,
|
||||
project_endpoint: str | None = None,
|
||||
model: str | None = None,
|
||||
credential: AsyncTokenCredential | None = None,
|
||||
credential: AzureCredentialTypes | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
) -> None:
|
||||
@@ -116,7 +116,8 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
|
||||
Ignored when a project_client is passed.
|
||||
model: The default model deployment name to use for agent creation.
|
||||
Can also be set via environment variable AZURE_AI_MODEL_DEPLOYMENT_NAME.
|
||||
credential: Azure async credential to use for authentication.
|
||||
credential: Azure credential for authentication. Accepts a TokenCredential,
|
||||
AsyncTokenCredential, or a callable token provider.
|
||||
Required when project_client is not provided.
|
||||
env_file_path: Path to environment file for loading settings.
|
||||
env_file_encoding: Encoding of the environment file.
|
||||
@@ -149,7 +150,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
|
||||
|
||||
project_client = AIProjectClient(
|
||||
endpoint=resolved_endpoint,
|
||||
credential=credential,
|
||||
credential=credential, # type: ignore[arg-type]
|
||||
user_agent=AGENT_FRAMEWORK_USER_AGENT,
|
||||
)
|
||||
self._should_close_client = True
|
||||
|
||||
@@ -30,6 +30,8 @@ _IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"AzureAISearchSettings": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"),
|
||||
"AzureAISettings": ("agent_framework_azure_ai", "agent-framework-azure-ai"),
|
||||
"AzureAIAgentsProvider": ("agent_framework_azure_ai", "agent-framework-azure-ai"),
|
||||
"AzureCredentialTypes": ("agent_framework.azure._entra_id_authentication", "agent-framework-core"),
|
||||
"AzureTokenProvider": ("agent_framework.azure._entra_id_authentication", "agent-framework-core"),
|
||||
"AzureOpenAIAssistantsClient": ("agent_framework.azure._assistants_client", "agent-framework-core"),
|
||||
"AzureOpenAIAssistantsOptions": ("agent_framework.azure._assistants_client", "agent-framework-core"),
|
||||
"AzureOpenAIChatClient": ("agent_framework.azure._chat_client", "agent-framework-core"),
|
||||
@@ -42,7 +44,6 @@ _IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"DurableAIAgentClient": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||
"DurableAIAgentOrchestrationContext": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||
"DurableAIAgentWorker": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||
"get_entra_auth_token": ("agent_framework.azure._entra_id_authentication", "agent-framework-core"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from agent_framework_durabletask import (
|
||||
|
||||
from agent_framework.azure._assistants_client import AzureOpenAIAssistantsClient
|
||||
from agent_framework.azure._chat_client import AzureOpenAIChatClient
|
||||
from agent_framework.azure._entra_id_authentication import get_entra_auth_token
|
||||
from agent_framework.azure._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider
|
||||
from agent_framework.azure._responses_client import AzureOpenAIResponsesClient
|
||||
from agent_framework.azure._shared import AzureOpenAISettings
|
||||
|
||||
@@ -37,13 +37,14 @@ __all__ = [
|
||||
"AzureAISearchContextProvider",
|
||||
"AzureAISearchSettings",
|
||||
"AzureAISettings",
|
||||
"AzureCredentialTypes",
|
||||
"AzureOpenAIAssistantsClient",
|
||||
"AzureOpenAIChatClient",
|
||||
"AzureOpenAIResponsesClient",
|
||||
"AzureOpenAISettings",
|
||||
"AzureTokenProvider",
|
||||
"DurableAIAgent",
|
||||
"DurableAIAgentClient",
|
||||
"DurableAIAgentOrchestrationContext",
|
||||
"DurableAIAgentWorker",
|
||||
"get_entra_auth_token",
|
||||
]
|
||||
|
||||
@@ -4,19 +4,16 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Generic
|
||||
from typing import Any, ClassVar, Generic
|
||||
|
||||
from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI
|
||||
from openai.lib.azure import AsyncAzureOpenAI
|
||||
|
||||
from .._settings import load_settings
|
||||
from ..exceptions import ServiceInitializationError
|
||||
from ..openai import OpenAIAssistantsClient
|
||||
from ..openai._assistants_client import OpenAIAssistantsOptions
|
||||
from ._entra_id_authentication import get_entra_auth_token
|
||||
from ._shared import DEFAULT_AZURE_TOKEN_ENDPOINT, AzureOpenAISettings, _apply_azure_defaults
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from azure.core.credentials import TokenCredential
|
||||
from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider, resolve_credential_to_token_provider
|
||||
from ._shared import AzureOpenAISettings, _apply_azure_defaults
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar # type: ignore # pragma: no cover
|
||||
@@ -61,10 +58,8 @@ class AzureOpenAIAssistantsClient(
|
||||
endpoint: str | None = None,
|
||||
base_url: str | None = None,
|
||||
api_version: str | None = None,
|
||||
ad_token: str | None = None,
|
||||
ad_token_provider: AsyncAzureADTokenProvider | None = None,
|
||||
token_endpoint: str | None = None,
|
||||
credential: TokenCredential | None = None,
|
||||
credential: AzureCredentialTypes | AzureTokenProvider | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
async_client: AsyncAzureOpenAI | None = None,
|
||||
env_file_path: str | None = None,
|
||||
@@ -93,11 +88,12 @@ class AzureOpenAIAssistantsClient(
|
||||
api_version: The deployment API version. If provided will override the value
|
||||
in the env vars or .env file.
|
||||
Can also be set via environment variable AZURE_OPENAI_API_VERSION.
|
||||
ad_token: The Azure Active Directory token.
|
||||
ad_token_provider: The Azure Active Directory token provider.
|
||||
token_endpoint: The token endpoint to request an Azure token.
|
||||
Can also be set via environment variable AZURE_OPENAI_TOKEN_ENDPOINT.
|
||||
credential: The Azure credential to use for authentication.
|
||||
credential: Azure credential or token provider for authentication. Accepts a
|
||||
``TokenCredential``, ``AsyncTokenCredential``, or a callable that returns a
|
||||
bearer token string (sync or async), for example from
|
||||
``azure.identity.get_bearer_token_provider()``.
|
||||
default_headers: The default headers mapping of string keys to
|
||||
string values for HTTP requests.
|
||||
async_client: An existing client to use.
|
||||
@@ -156,20 +152,15 @@ class AzureOpenAIAssistantsClient(
|
||||
"or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable."
|
||||
)
|
||||
|
||||
# Handle authentication: try API key first, then AD token, then Entra ID
|
||||
if (
|
||||
not async_client
|
||||
and not azure_openai_settings["api_key"]
|
||||
and not ad_token
|
||||
and not ad_token_provider
|
||||
and azure_openai_settings["token_endpoint"]
|
||||
and credential
|
||||
):
|
||||
token_ep = azure_openai_settings["token_endpoint"] or DEFAULT_AZURE_TOKEN_ENDPOINT
|
||||
ad_token = get_entra_auth_token(credential, token_ep)
|
||||
# Resolve credential to token provider
|
||||
ad_token_provider = None
|
||||
if not async_client and not azure_openai_settings["api_key"] and credential:
|
||||
ad_token_provider = resolve_credential_to_token_provider(
|
||||
credential, azure_openai_settings["token_endpoint"]
|
||||
)
|
||||
|
||||
if not async_client and not azure_openai_settings["api_key"] and not ad_token and not ad_token_provider:
|
||||
raise ServiceInitializationError("The Azure OpenAI API key, ad_token, or ad_token_provider is required.")
|
||||
if not async_client and not azure_openai_settings["api_key"] and not ad_token_provider:
|
||||
raise ServiceInitializationError("Please provide either api_key, credential, or a client.")
|
||||
|
||||
# Create Azure client if not provided
|
||||
if not async_client:
|
||||
@@ -180,8 +171,6 @@ class AzureOpenAIAssistantsClient(
|
||||
|
||||
if azure_openai_settings["api_key"]:
|
||||
client_params["api_key"] = azure_openai_settings["api_key"].get_secret_value()
|
||||
elif ad_token:
|
||||
client_params["azure_ad_token"] = ad_token
|
||||
elif ad_token_provider:
|
||||
client_params["azure_ad_token_provider"] = ad_token_provider
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@ import sys
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Generic
|
||||
|
||||
from azure.core.credentials import TokenCredential
|
||||
from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI
|
||||
from openai.lib.azure import AsyncAzureOpenAI
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
|
||||
from pydantic import BaseModel
|
||||
@@ -29,6 +28,7 @@ from agent_framework.openai import OpenAIChatOptions
|
||||
from agent_framework.openai._chat_client import RawOpenAIChatClient
|
||||
|
||||
from .._settings import load_settings
|
||||
from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider
|
||||
from ._shared import (
|
||||
AzureOpenAIConfigMixin,
|
||||
AzureOpenAISettings,
|
||||
@@ -169,10 +169,8 @@ class AzureOpenAIChatClient( # type: ignore[misc]
|
||||
endpoint: str | None = None,
|
||||
base_url: str | None = None,
|
||||
api_version: str | None = None,
|
||||
ad_token: str | None = None,
|
||||
ad_token_provider: AsyncAzureADTokenProvider | None = None,
|
||||
token_endpoint: str | None = None,
|
||||
credential: TokenCredential | None = None,
|
||||
credential: AzureCredentialTypes | AzureTokenProvider | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
async_client: AsyncAzureOpenAI | None = None,
|
||||
env_file_path: str | None = None,
|
||||
@@ -199,11 +197,12 @@ class AzureOpenAIChatClient( # type: ignore[misc]
|
||||
api_version: The deployment API version. If provided will override the value
|
||||
in the env vars or .env file.
|
||||
Can also be set via environment variable AZURE_OPENAI_API_VERSION.
|
||||
ad_token: The Azure Active Directory token.
|
||||
ad_token_provider: The Azure Active Directory token provider.
|
||||
token_endpoint: The token endpoint to request an Azure token.
|
||||
Can also be set via environment variable AZURE_OPENAI_TOKEN_ENDPOINT.
|
||||
credential: The Azure credential for authentication.
|
||||
credential: Azure credential or token provider for authentication. Accepts a
|
||||
``TokenCredential``, ``AsyncTokenCredential``, or a callable that returns a
|
||||
bearer token string (sync or async), for example from
|
||||
``azure.identity.get_bearer_token_provider()``.
|
||||
default_headers: The default headers mapping of string keys to
|
||||
string values for HTTP requests.
|
||||
async_client: An existing client to use.
|
||||
@@ -274,8 +273,6 @@ class AzureOpenAIChatClient( # type: ignore[misc]
|
||||
base_url=azure_openai_settings["base_url"],
|
||||
api_version=azure_openai_settings["api_version"], # type: ignore
|
||||
api_key=azure_openai_settings["api_key"].get_secret_value() if azure_openai_settings["api_key"] else None,
|
||||
ad_token=ad_token,
|
||||
ad_token_provider=ad_token_provider,
|
||||
token_endpoint=azure_openai_settings["token_endpoint"],
|
||||
credential=credential,
|
||||
default_headers=default_headers,
|
||||
|
||||
@@ -3,80 +3,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Union
|
||||
|
||||
from azure.core.exceptions import ClientAuthenticationError
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
from ..exceptions import ServiceInvalidAuthError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
AzureTokenProvider = Callable[[], Union[str, Awaitable[str]]]
|
||||
"""A callable that returns a bearer token string, either synchronously or asynchronously."""
|
||||
|
||||
def get_entra_auth_token(
|
||||
credential: TokenCredential,
|
||||
token_endpoint: str,
|
||||
**kwargs: Any,
|
||||
) -> str | None:
|
||||
"""Retrieve a Microsoft Entra Auth Token for a given token endpoint.
|
||||
AzureCredentialTypes = Union[TokenCredential, AsyncTokenCredential]
|
||||
"""Union of Azure credential types.
|
||||
|
||||
The token endpoint may be specified as an environment variable, via the .env
|
||||
file or as an argument. If the token endpoint is not provided, the default is None.
|
||||
Accepts:
|
||||
- ``TokenCredential`` — synchronous Azure credential (e.g. ``DefaultAzureCredential()``)
|
||||
- ``AsyncTokenCredential`` — asynchronous Azure credential (e.g. ``azure.identity.aio.DefaultAzureCredential()``)
|
||||
"""
|
||||
|
||||
|
||||
def resolve_credential_to_token_provider(
|
||||
credential: AzureCredentialTypes | AzureTokenProvider,
|
||||
token_endpoint: str | None,
|
||||
) -> AzureTokenProvider:
|
||||
"""Convert an Azure credential or token provider into an ``ad_token_provider`` callable.
|
||||
|
||||
If the credential is already a callable token provider, it is returned as-is
|
||||
(``token_endpoint`` is not required in this case).
|
||||
If it is a ``TokenCredential`` or ``AsyncTokenCredential``, it is wrapped using
|
||||
``azure.identity.get_bearer_token_provider`` (sync or async variant) which
|
||||
handles token caching and automatic refresh.
|
||||
|
||||
Args:
|
||||
credential: The Azure credential to use for authentication.
|
||||
token_endpoint: The token endpoint to use to retrieve the authentication token.
|
||||
|
||||
Keyword Args:
|
||||
**kwargs: Additional keyword arguments to pass to the token retrieval method.
|
||||
credential: An Azure credential or token provider callable.
|
||||
token_endpoint: The token scope/endpoint
|
||||
(e.g. ``"https://cognitiveservices.azure.com/.default"``).
|
||||
Required when ``credential`` is a ``TokenCredential`` or ``AsyncTokenCredential``.
|
||||
|
||||
Returns:
|
||||
The Azure token or None if the token could not be retrieved.
|
||||
A callable that returns a bearer token string (sync or async).
|
||||
|
||||
Raises:
|
||||
ServiceInvalidAuthError: If the token endpoint is empty when needed for credential wrapping.
|
||||
"""
|
||||
# Already a token provider callable (not a credential object) — use directly
|
||||
if callable(credential) and not isinstance(credential, (TokenCredential, AsyncTokenCredential)):
|
||||
return credential
|
||||
|
||||
if not token_endpoint:
|
||||
raise ServiceInvalidAuthError(
|
||||
"A token endpoint must be provided either in settings, as an environment variable, or as an argument."
|
||||
)
|
||||
|
||||
try:
|
||||
auth_token = credential.get_token(token_endpoint, **kwargs)
|
||||
except ClientAuthenticationError as ex:
|
||||
logger.error(f"Failed to retrieve Azure token for the specified endpoint: `{token_endpoint}`, with error: {ex}")
|
||||
return None
|
||||
if isinstance(credential, AsyncTokenCredential):
|
||||
from azure.identity.aio import get_bearer_token_provider as get_async_bearer_token_provider
|
||||
|
||||
return auth_token.token if auth_token else None
|
||||
return get_async_bearer_token_provider(credential, token_endpoint)
|
||||
|
||||
from azure.identity import get_bearer_token_provider
|
||||
|
||||
async def get_entra_auth_token_async(
|
||||
credential: AsyncTokenCredential, token_endpoint: str, **kwargs: Any
|
||||
) -> str | None:
|
||||
"""Retrieve a async Microsoft Entra Auth Token for a given token endpoint.
|
||||
|
||||
The token endpoint may be specified as an environment variable, via the .env
|
||||
file or as an argument. If the token endpoint is not provided, the default is None.
|
||||
|
||||
Args:
|
||||
credential: The async Azure credential to use for authentication.
|
||||
token_endpoint: The token endpoint to use to retrieve the authentication token.
|
||||
|
||||
Keyword Args:
|
||||
**kwargs: Additional keyword arguments to pass to the token retrieval method.
|
||||
|
||||
Returns:
|
||||
The Azure token or None if the token could not be retrieved.
|
||||
"""
|
||||
if not token_endpoint:
|
||||
raise ServiceInvalidAuthError(
|
||||
"A token endpoint must be provided either in settings, as an environment variable, or as an argument."
|
||||
)
|
||||
|
||||
try:
|
||||
auth_token = await credential.get_token(token_endpoint, **kwargs)
|
||||
except ClientAuthenticationError as ex:
|
||||
logger.error(f"Failed to retrieve Azure token for the specified endpoint: `{token_endpoint}`, with error: {ex}")
|
||||
return None
|
||||
|
||||
return auth_token.token if auth_token else None
|
||||
return get_bearer_token_provider(credential, token_endpoint) # type: ignore[arg-type]
|
||||
|
||||
@@ -8,9 +8,7 @@ from typing import TYPE_CHECKING, Any, Generic
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.core.credentials import TokenCredential
|
||||
from openai import AsyncOpenAI
|
||||
from openai.lib.azure import AsyncAzureADTokenProvider
|
||||
|
||||
from .._middleware import ChatMiddlewareLayer
|
||||
from .._settings import load_settings
|
||||
@@ -19,6 +17,7 @@ from .._tools import FunctionInvocationConfiguration, FunctionInvocationLayer
|
||||
from ..exceptions import ServiceInitializationError
|
||||
from ..observability import ChatTelemetryLayer
|
||||
from ..openai._responses_client import RawOpenAIResponsesClient
|
||||
from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider
|
||||
from ._shared import (
|
||||
AzureOpenAIConfigMixin,
|
||||
AzureOpenAISettings,
|
||||
@@ -69,10 +68,8 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
endpoint: str | None = None,
|
||||
base_url: str | None = None,
|
||||
api_version: str | None = None,
|
||||
ad_token: str | None = None,
|
||||
ad_token_provider: AsyncAzureADTokenProvider | None = None,
|
||||
token_endpoint: str | None = None,
|
||||
credential: TokenCredential | None = None,
|
||||
credential: AzureCredentialTypes | AzureTokenProvider | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
async_client: AsyncOpenAI | None = None,
|
||||
project_client: Any | None = None,
|
||||
@@ -109,11 +106,12 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
api_version: The deployment API version. If provided will override the value
|
||||
in the env vars or .env file. Currently, the api_version must be "preview".
|
||||
Can also be set via environment variable AZURE_OPENAI_API_VERSION.
|
||||
ad_token: The Azure Active Directory token.
|
||||
ad_token_provider: The Azure Active Directory token provider.
|
||||
token_endpoint: The token endpoint to request an Azure token.
|
||||
Can also be set via environment variable AZURE_OPENAI_TOKEN_ENDPOINT.
|
||||
credential: The Azure credential for authentication.
|
||||
credential: Azure credential or token provider for authentication. Accepts a
|
||||
``TokenCredential``, ``AsyncTokenCredential``, or a callable that returns a
|
||||
bearer token string (sync or async), for example from
|
||||
``azure.identity.get_bearer_token_provider()``.
|
||||
default_headers: The default headers mapping of string keys to
|
||||
string values for HTTP requests.
|
||||
async_client: An existing client to use.
|
||||
@@ -230,8 +228,6 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
base_url=azure_openai_settings["base_url"],
|
||||
api_version=azure_openai_settings["api_version"], # type: ignore
|
||||
api_key=azure_openai_settings["api_key"].get_secret_value() if azure_openai_settings["api_key"] else None,
|
||||
ad_token=ad_token,
|
||||
ad_token_provider=ad_token_provider,
|
||||
token_endpoint=azure_openai_settings["token_endpoint"],
|
||||
credential=credential,
|
||||
default_headers=default_headers,
|
||||
@@ -246,7 +242,7 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
*,
|
||||
project_client: AIProjectClient | None,
|
||||
project_endpoint: str | None,
|
||||
credential: TokenCredential | None,
|
||||
credential: AzureCredentialTypes | AzureTokenProvider | None,
|
||||
) -> AsyncOpenAI:
|
||||
"""Create an AsyncOpenAI client from an Azure AI Foundry project.
|
||||
|
||||
|
||||
@@ -4,11 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from collections.abc import Mapping
|
||||
from copy import copy
|
||||
from typing import Any, ClassVar, Final
|
||||
|
||||
from azure.core.credentials import TokenCredential
|
||||
from openai import AsyncOpenAI
|
||||
from openai.lib.azure import AsyncAzureOpenAI
|
||||
|
||||
@@ -16,7 +15,7 @@ from .._settings import SecretString
|
||||
from .._telemetry import APP_INFO, prepend_agent_framework_to_user_agent
|
||||
from ..exceptions import ServiceInitializationError
|
||||
from ..openai._shared import OpenAIBase
|
||||
from ._entra_id_authentication import get_entra_auth_token
|
||||
from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider, resolve_credential_to_token_provider
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -135,10 +134,8 @@ class AzureOpenAIConfigMixin(OpenAIBase):
|
||||
base_url: str | None = None,
|
||||
api_version: str = DEFAULT_AZURE_API_VERSION,
|
||||
api_key: str | None = None,
|
||||
ad_token: str | None = None,
|
||||
ad_token_provider: Callable[[], str | Awaitable[str]] | None = None,
|
||||
token_endpoint: str | None = None,
|
||||
credential: TokenCredential | None = None,
|
||||
credential: AzureCredentialTypes | AzureTokenProvider | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
client: AsyncOpenAI | None = None,
|
||||
instruction_role: str | None = None,
|
||||
@@ -155,10 +152,10 @@ class AzureOpenAIConfigMixin(OpenAIBase):
|
||||
base_url: The base URL for Azure services.
|
||||
api_version: Azure API version. Defaults to the defined DEFAULT_AZURE_API_VERSION.
|
||||
api_key: API key for Azure services.
|
||||
ad_token: Azure AD token for authentication.
|
||||
ad_token_provider: A callable or coroutine function providing Azure AD tokens.
|
||||
token_endpoint: Azure AD token endpoint use to get the token.
|
||||
credential: Azure credential for authentication.
|
||||
token_endpoint: Azure AD token scope used to obtain a bearer token from a credential.
|
||||
credential: Azure credential or token provider for authentication. Accepts a
|
||||
``TokenCredential``, ``AsyncTokenCredential``, or a callable that returns a
|
||||
bearer token string (sync or async).
|
||||
default_headers: Default headers for HTTP requests.
|
||||
client: An existing client to use.
|
||||
instruction_role: The role to use for 'instruction' messages, for example, summarization
|
||||
@@ -172,16 +169,13 @@ class AzureOpenAIConfigMixin(OpenAIBase):
|
||||
merged_headers.update(APP_INFO)
|
||||
merged_headers = prepend_agent_framework_to_user_agent(merged_headers)
|
||||
if not client:
|
||||
# If the client is None, the api_key is none, the ad_token is none, and the ad_token_provider is none,
|
||||
# then we will attempt to get the ad_token using the default endpoint specified in the Azure OpenAI
|
||||
# settings.
|
||||
if not api_key and not ad_token_provider and not ad_token and token_endpoint and credential:
|
||||
ad_token = get_entra_auth_token(credential, token_endpoint)
|
||||
# Resolve credential to a token provider if needed
|
||||
ad_token_provider = None
|
||||
if not api_key and credential:
|
||||
ad_token_provider = resolve_credential_to_token_provider(credential, token_endpoint)
|
||||
|
||||
if not api_key and not ad_token and not ad_token_provider:
|
||||
raise ServiceInitializationError(
|
||||
"Please provide either api_key, ad_token or ad_token_provider or a client."
|
||||
)
|
||||
if not api_key and not ad_token_provider:
|
||||
raise ServiceInitializationError("Please provide either api_key, credential, or a client.")
|
||||
|
||||
if not endpoint and not base_url:
|
||||
raise ServiceInitializationError("Please provide an endpoint or a base_url")
|
||||
@@ -191,8 +185,6 @@ class AzureOpenAIConfigMixin(OpenAIBase):
|
||||
}
|
||||
if api_version:
|
||||
args["api_version"] = api_version
|
||||
if ad_token:
|
||||
args["azure_ad_token"] = ad_token
|
||||
if ad_token_provider:
|
||||
args["azure_ad_token_provider"] = ad_token_provider
|
||||
if api_key:
|
||||
|
||||
@@ -551,12 +551,16 @@ async def test_azure_assistants_client_agent_level_tool_persistence():
|
||||
|
||||
|
||||
def test_azure_assistants_client_entra_id_authentication() -> None:
|
||||
"""Test Entra ID authentication path with credential."""
|
||||
"""Test credential authentication path with sync credential."""
|
||||
mock_credential = MagicMock()
|
||||
mock_provider = MagicMock(return_value="token-string")
|
||||
|
||||
with (
|
||||
patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings,
|
||||
patch("agent_framework.azure._assistants_client.get_entra_auth_token") as mock_get_token,
|
||||
patch(
|
||||
"agent_framework.azure._assistants_client.resolve_credential_to_token_provider",
|
||||
return_value=mock_provider,
|
||||
) as mock_resolve,
|
||||
patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client,
|
||||
patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None),
|
||||
):
|
||||
@@ -564,28 +568,26 @@ def test_azure_assistants_client_entra_id_authentication() -> None:
|
||||
"chat_deployment_name": "test-deployment",
|
||||
"responses_deployment_name": None,
|
||||
"api_key": None,
|
||||
"token_endpoint": "https://login.microsoftonline.com/test",
|
||||
"token_endpoint": "https://cognitiveservices.azure.com/.default",
|
||||
"api_version": "2024-05-01-preview",
|
||||
"endpoint": "https://test-endpoint.openai.azure.com",
|
||||
"base_url": None,
|
||||
}
|
||||
mock_get_token.return_value = "entra-token-12345"
|
||||
|
||||
client = AzureOpenAIAssistantsClient(
|
||||
deployment_name="test-deployment",
|
||||
api_key="placeholder-key",
|
||||
endpoint="https://test-endpoint.openai.azure.com",
|
||||
credential=mock_credential,
|
||||
token_endpoint="https://login.microsoftonline.com/test",
|
||||
token_endpoint="https://cognitiveservices.azure.com/.default",
|
||||
)
|
||||
|
||||
# Verify Entra ID token was requested
|
||||
mock_get_token.assert_called_once_with(mock_credential, "https://login.microsoftonline.com/test")
|
||||
# Verify credential was resolved to a token provider
|
||||
mock_resolve.assert_called_once_with(mock_credential, "https://cognitiveservices.azure.com/.default")
|
||||
|
||||
# Verify client was created with the token
|
||||
# Verify client was created with the token provider
|
||||
mock_azure_client.assert_called_once()
|
||||
call_args = mock_azure_client.call_args[1]
|
||||
assert call_args["azure_ad_token"] == "entra-token-12345"
|
||||
assert call_args["azure_ad_token_provider"] is mock_provider
|
||||
|
||||
assert client is not None
|
||||
assert isinstance(client, AzureOpenAIAssistantsClient)
|
||||
@@ -605,7 +607,7 @@ def test_azure_assistants_client_no_authentication_error() -> None:
|
||||
}
|
||||
|
||||
# Test missing authentication raises error
|
||||
with pytest.raises(ServiceInitializationError, match="API key, ad_token, or ad_token_provider is required"):
|
||||
with pytest.raises(ServiceInitializationError, match="api_key, credential, or a client"):
|
||||
AzureOpenAIAssistantsClient(
|
||||
deployment_name="test-deployment",
|
||||
endpoint="https://test-endpoint.openai.azure.com",
|
||||
@@ -613,10 +615,16 @@ def test_azure_assistants_client_no_authentication_error() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_azure_assistants_client_ad_token_authentication() -> None:
|
||||
"""Test ad_token authentication client parameter path."""
|
||||
def test_azure_assistants_client_callable_credential() -> None:
|
||||
"""Test callable token provider as credential."""
|
||||
mock_provider = MagicMock(return_value="my-token")
|
||||
|
||||
with (
|
||||
patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings,
|
||||
patch(
|
||||
"agent_framework.azure._assistants_client.resolve_credential_to_token_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client,
|
||||
patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None),
|
||||
):
|
||||
@@ -624,7 +632,7 @@ def test_azure_assistants_client_ad_token_authentication() -> None:
|
||||
"chat_deployment_name": "test-deployment",
|
||||
"responses_deployment_name": None,
|
||||
"api_key": None,
|
||||
"token_endpoint": None,
|
||||
"token_endpoint": "https://cognitiveservices.azure.com/.default",
|
||||
"api_version": "2024-05-01-preview",
|
||||
"endpoint": "https://test-endpoint.openai.azure.com",
|
||||
"base_url": None,
|
||||
@@ -633,49 +641,14 @@ def test_azure_assistants_client_ad_token_authentication() -> None:
|
||||
client = AzureOpenAIAssistantsClient(
|
||||
deployment_name="test-deployment",
|
||||
endpoint="https://test-endpoint.openai.azure.com",
|
||||
ad_token="test-ad-token-12345",
|
||||
credential=mock_provider,
|
||||
token_endpoint="https://cognitiveservices.azure.com/.default",
|
||||
)
|
||||
|
||||
# ad_token path
|
||||
# Verify client was created with the token provider
|
||||
mock_azure_client.assert_called_once()
|
||||
call_args = mock_azure_client.call_args[1]
|
||||
assert call_args["azure_ad_token"] == "test-ad-token-12345"
|
||||
|
||||
assert client is not None
|
||||
assert isinstance(client, AzureOpenAIAssistantsClient)
|
||||
|
||||
|
||||
def test_azure_assistants_client_ad_token_provider_authentication() -> None:
|
||||
"""Test ad_token_provider authentication client parameter path."""
|
||||
from openai.lib.azure import AsyncAzureADTokenProvider
|
||||
|
||||
mock_token_provider = MagicMock(spec=AsyncAzureADTokenProvider)
|
||||
|
||||
with (
|
||||
patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings,
|
||||
patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client,
|
||||
patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None),
|
||||
):
|
||||
mock_load_settings.return_value = {
|
||||
"chat_deployment_name": "test-deployment",
|
||||
"responses_deployment_name": None,
|
||||
"api_key": None,
|
||||
"token_endpoint": None,
|
||||
"api_version": "2024-05-01-preview",
|
||||
"endpoint": "https://test-endpoint.openai.azure.com",
|
||||
"base_url": None,
|
||||
}
|
||||
|
||||
client = AzureOpenAIAssistantsClient(
|
||||
deployment_name="test-deployment",
|
||||
endpoint="https://test-endpoint.openai.azure.com",
|
||||
ad_token_provider=mock_token_provider,
|
||||
)
|
||||
|
||||
# ad_token_provider path
|
||||
mock_azure_client.assert_called_once()
|
||||
call_args = mock_azure_client.call_args[1]
|
||||
assert call_args["azure_ad_token_provider"] is mock_token_provider
|
||||
assert call_args["azure_ad_token_provider"] is mock_provider
|
||||
|
||||
assert client is not None
|
||||
assert isinstance(client, AzureOpenAIAssistantsClient)
|
||||
|
||||
@@ -1,156 +1,61 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from azure.core.exceptions import ClientAuthenticationError
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
from agent_framework.azure._entra_id_authentication import (
|
||||
get_entra_auth_token,
|
||||
get_entra_auth_token_async,
|
||||
resolve_credential_to_token_provider,
|
||||
)
|
||||
from agent_framework.exceptions import ServiceInvalidAuthError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_credential() -> MagicMock:
|
||||
"""Mock synchronous TokenCredential."""
|
||||
mock_cred = MagicMock()
|
||||
# Create a mock token object with a .token attribute
|
||||
mock_token = MagicMock()
|
||||
mock_token.token = "test-access-token-12345"
|
||||
mock_cred.get_token.return_value = mock_token
|
||||
return mock_cred
|
||||
TOKEN_ENDPOINT = "https://cognitiveservices.azure.com/.default"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_async_credential() -> MagicMock:
|
||||
"""Mock asynchronous AsyncTokenCredential."""
|
||||
mock_cred = MagicMock()
|
||||
# Create a mock token object with a .token attribute
|
||||
mock_token = MagicMock()
|
||||
mock_token.token = "test-async-access-token-12345"
|
||||
mock_cred.get_token = AsyncMock(return_value=mock_token)
|
||||
return mock_cred
|
||||
def test_resolve_sync_credential_returns_provider() -> None:
|
||||
"""Test that a sync TokenCredential is resolved via azure.identity.get_bearer_token_provider."""
|
||||
mock_credential = MagicMock(spec=TokenCredential)
|
||||
mock_provider = MagicMock(return_value="token-string")
|
||||
|
||||
with patch("azure.identity.get_bearer_token_provider", return_value=mock_provider) as mock_gbtp:
|
||||
result = resolve_credential_to_token_provider(mock_credential, TOKEN_ENDPOINT)
|
||||
|
||||
mock_gbtp.assert_called_once_with(mock_credential, TOKEN_ENDPOINT)
|
||||
assert result is mock_provider
|
||||
|
||||
|
||||
def test_get_entra_auth_token_success(mock_credential: MagicMock) -> None:
|
||||
"""Test successful token retrieval with sync function."""
|
||||
def test_resolve_async_credential_returns_provider() -> None:
|
||||
"""Test that an AsyncTokenCredential is resolved via azure.identity.aio.get_bearer_token_provider."""
|
||||
mock_credential = MagicMock(spec=AsyncTokenCredential)
|
||||
mock_provider = MagicMock(return_value="token-string")
|
||||
|
||||
token_endpoint = "https://test-endpoint.com/.default"
|
||||
with patch("azure.identity.aio.get_bearer_token_provider", return_value=mock_provider) as mock_gbtp:
|
||||
result = resolve_credential_to_token_provider(mock_credential, TOKEN_ENDPOINT)
|
||||
|
||||
result = get_entra_auth_token(mock_credential, token_endpoint)
|
||||
|
||||
# Assert - check the results
|
||||
assert result == "test-access-token-12345"
|
||||
mock_credential.get_token.assert_called_once_with(token_endpoint)
|
||||
mock_gbtp.assert_called_once_with(mock_credential, TOKEN_ENDPOINT)
|
||||
assert result is mock_provider
|
||||
|
||||
|
||||
async def test_get_entra_auth_token_async_success(mock_async_credential: MagicMock) -> None:
|
||||
"""Test successful token retrieval with async function."""
|
||||
def test_resolve_callable_provider_passthrough() -> None:
|
||||
"""Test that a callable token provider is returned as-is, without needing token_endpoint."""
|
||||
my_provider = lambda: "my-token" # noqa: E731
|
||||
|
||||
token_endpoint = "https://test-endpoint.com/.default"
|
||||
# Works with token_endpoint
|
||||
assert resolve_credential_to_token_provider(my_provider, TOKEN_ENDPOINT) is my_provider
|
||||
|
||||
result = await get_entra_auth_token_async(mock_async_credential, token_endpoint)
|
||||
|
||||
# Assert - check the results
|
||||
assert result == "test-async-access-token-12345"
|
||||
mock_async_credential.get_token.assert_called_once_with(token_endpoint)
|
||||
# Also works without token_endpoint
|
||||
assert resolve_credential_to_token_provider(my_provider, None) is my_provider
|
||||
assert resolve_credential_to_token_provider(my_provider, "") is my_provider
|
||||
|
||||
|
||||
def test_get_entra_auth_token_missing_endpoint(mock_credential: MagicMock) -> None:
|
||||
def test_resolve_missing_endpoint_raises() -> None:
|
||||
"""Test that missing token endpoint raises ServiceInvalidAuthError."""
|
||||
# Test with empty string
|
||||
mock_credential = MagicMock(spec=TokenCredential)
|
||||
|
||||
with pytest.raises(ServiceInvalidAuthError, match="A token endpoint must be provided"):
|
||||
get_entra_auth_token(mock_credential, "")
|
||||
resolve_credential_to_token_provider(mock_credential, "")
|
||||
|
||||
# Test with None
|
||||
with pytest.raises(ServiceInvalidAuthError, match="A token endpoint must be provided"):
|
||||
get_entra_auth_token(mock_credential, None) # type: ignore
|
||||
|
||||
|
||||
async def test_get_entra_auth_token_async_missing_endpoint(mock_async_credential: MagicMock) -> None:
|
||||
"""Test that missing token endpoint raises ServiceInvalidAuthError in async function."""
|
||||
# Test with empty string
|
||||
with pytest.raises(ServiceInvalidAuthError, match="A token endpoint must be provided"):
|
||||
await get_entra_auth_token_async(mock_async_credential, "")
|
||||
|
||||
# Test with None
|
||||
with pytest.raises(ServiceInvalidAuthError, match="A token endpoint must be provided"):
|
||||
await get_entra_auth_token_async(mock_async_credential, None) # type: ignore
|
||||
|
||||
|
||||
def test_get_entra_auth_token_auth_failure(mock_credential: MagicMock) -> None:
|
||||
"""Test that Azure authentication failure returns None."""
|
||||
|
||||
mock_credential.get_token.side_effect = ClientAuthenticationError("Auth failed")
|
||||
token_endpoint = "https://test-endpoint.com/.default"
|
||||
|
||||
result = get_entra_auth_token(mock_credential, token_endpoint)
|
||||
|
||||
# Assert - should return None on auth failure
|
||||
assert result is None
|
||||
mock_credential.get_token.assert_called_once_with(token_endpoint)
|
||||
|
||||
|
||||
async def test_get_entra_auth_token_async_auth_failure(mock_async_credential: MagicMock) -> None:
|
||||
"""Test that Azure authentication failure returns None in async function."""
|
||||
|
||||
mock_async_credential.get_token.side_effect = ClientAuthenticationError("Auth failed")
|
||||
token_endpoint = "https://test-endpoint.com/.default"
|
||||
|
||||
result = await get_entra_auth_token_async(mock_async_credential, token_endpoint)
|
||||
|
||||
# Assert - should return None on auth failure
|
||||
assert result is None
|
||||
mock_async_credential.get_token.assert_called_once_with(token_endpoint)
|
||||
|
||||
|
||||
def test_get_entra_auth_token_none_token_response(mock_credential: MagicMock) -> None:
|
||||
"""Test that None token response returns None."""
|
||||
mock_credential.get_token.return_value = None
|
||||
token_endpoint = "https://test-endpoint.com/.default"
|
||||
|
||||
result = get_entra_auth_token(mock_credential, token_endpoint)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
mock_credential.get_token.assert_called_once_with(token_endpoint)
|
||||
|
||||
|
||||
async def test_get_entra_auth_token_async_none_token_response(mock_async_credential: MagicMock) -> None:
|
||||
"""Test that None token response returns None in async function."""
|
||||
mock_async_credential.get_token.return_value = None
|
||||
token_endpoint = "https://test-endpoint.com/.default"
|
||||
|
||||
result = await get_entra_auth_token_async(mock_async_credential, token_endpoint)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
mock_async_credential.get_token.assert_called_once_with(token_endpoint)
|
||||
|
||||
|
||||
def test_get_entra_auth_token_with_kwargs(mock_credential: MagicMock) -> None:
|
||||
"""Test that kwargs are passed through to get_token."""
|
||||
|
||||
token_endpoint = "https://test-endpoint.com/.default"
|
||||
extra_kwargs = {"scopes": ["read", "write"], "tenant_id": "test-tenant"}
|
||||
|
||||
result = get_entra_auth_token(mock_credential, token_endpoint, **extra_kwargs)
|
||||
|
||||
# Assert
|
||||
assert result == "test-access-token-12345"
|
||||
mock_credential.get_token.assert_called_once_with(token_endpoint, **extra_kwargs)
|
||||
|
||||
|
||||
async def test_get_entra_auth_token_async_with_kwargs(mock_async_credential: MagicMock) -> None:
|
||||
"""Test that kwargs are passed through to async get_token."""
|
||||
|
||||
token_endpoint = "https://test-endpoint.com/.default"
|
||||
extra_kwargs = {"scopes": ["read", "write"], "tenant_id": "test-tenant"}
|
||||
|
||||
result = await get_entra_auth_token_async(mock_async_credential, token_endpoint, **extra_kwargs)
|
||||
|
||||
# Assert
|
||||
assert result == "test-async-access-token-12345"
|
||||
mock_async_credential.get_token.assert_called_once_with(token_endpoint, **extra_kwargs)
|
||||
resolve_credential_to_token_provider(mock_credential, None) # type: ignore[arg-type]
|
||||
|
||||
@@ -11,6 +11,7 @@ from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
from agent_framework import AGENT_FRAMEWORK_USER_AGENT
|
||||
from agent_framework.azure._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider
|
||||
from agent_framework.observability import get_tracer
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
@@ -39,18 +40,19 @@ logger = logging.getLogger("agent_framework.purview")
|
||||
class PurviewClient:
|
||||
"""Async client for calling Graph Purview endpoints.
|
||||
|
||||
Supports both synchronous TokenCredential and asynchronous AsyncTokenCredential implementations.
|
||||
A sync credential will be invoked in a thread to avoid blocking the event loop.
|
||||
Supports synchronous TokenCredential, asynchronous AsyncTokenCredential,
|
||||
or callable token providers. A sync credential will be invoked in a thread
|
||||
to avoid blocking the event loop.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
credential: TokenCredential | AsyncTokenCredential,
|
||||
credential: AzureCredentialTypes | AzureTokenProvider,
|
||||
settings: PurviewSettings,
|
||||
*,
|
||||
timeout: float | None = 10.0,
|
||||
):
|
||||
self._credential: TokenCredential | AsyncTokenCredential = credential
|
||||
self._credential: AzureCredentialTypes | AzureTokenProvider = credential
|
||||
self._settings = settings
|
||||
self._graph_uri = (settings.get("graph_base_uri") or "https://graph.microsoft.com/v1.0/").rstrip("/")
|
||||
self._timeout = timeout
|
||||
@@ -60,10 +62,14 @@ class PurviewClient:
|
||||
await self._client.aclose()
|
||||
|
||||
async def _get_token(self, *, tenant_id: str | None = None) -> str:
|
||||
"""Acquire an access token using either async or sync credential."""
|
||||
scopes = get_purview_scopes(self._settings)
|
||||
"""Acquire an access token using either async or sync credential, or callable token provider."""
|
||||
cred = self._credential
|
||||
token = cred.get_token(*scopes, tenant_id=tenant_id)
|
||||
# Callable token provider — returns a token string directly
|
||||
if callable(cred) and not isinstance(cred, (TokenCredential, AsyncTokenCredential)):
|
||||
result = cred()
|
||||
return await result if inspect.isawaitable(result) else result # type: ignore[return-value]
|
||||
scopes = get_purview_scopes(self._settings)
|
||||
token = cred.get_token(*scopes, tenant_id=tenant_id) # type: ignore[union-attr]
|
||||
token = await token if inspect.isawaitable(token) else token
|
||||
return token.token
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from agent_framework import AgentContext, AgentMiddleware, ChatContext, ChatMiddleware, MiddlewareTermination
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from agent_framework.azure._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider
|
||||
|
||||
from ._cache import CacheProvider
|
||||
from ._client import PurviewClient
|
||||
@@ -20,7 +19,7 @@ logger = logging.getLogger("agent_framework.purview")
|
||||
class PurviewPolicyMiddleware(AgentMiddleware):
|
||||
"""Agent middleware that enforces Purview policies on prompt and response.
|
||||
|
||||
Accepts either a synchronous TokenCredential or an AsyncTokenCredential.
|
||||
Accepts a TokenCredential, AsyncTokenCredential, or callable token provider.
|
||||
|
||||
Usage:
|
||||
|
||||
@@ -28,14 +27,14 @@ class PurviewPolicyMiddleware(AgentMiddleware):
|
||||
from agent_framework.microsoft import PurviewPolicyMiddleware, PurviewSettings
|
||||
from agent_framework import Agent
|
||||
|
||||
credential = ... # TokenCredential or AsyncTokenCredential
|
||||
credential = ... # TokenCredential, AsyncTokenCredential, or callable
|
||||
settings = PurviewSettings(app_name="My App")
|
||||
agent = Agent(client=client, instructions="...", middleware=[PurviewPolicyMiddleware(credential, settings)])
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
credential: TokenCredential | AsyncTokenCredential,
|
||||
credential: AzureCredentialTypes | AzureTokenProvider,
|
||||
settings: PurviewSettings,
|
||||
cache_provider: CacheProvider | None = None,
|
||||
) -> None:
|
||||
@@ -153,14 +152,14 @@ class PurviewChatPolicyMiddleware(ChatMiddleware):
|
||||
from agent_framework.microsoft import PurviewChatPolicyMiddleware, PurviewSettings
|
||||
from agent_framework import ChatClient
|
||||
|
||||
credential = ... # TokenCredential or AsyncTokenCredential
|
||||
credential = ... # TokenCredential, AsyncTokenCredential, or callable
|
||||
settings = PurviewSettings(app_name="My App")
|
||||
client = ChatClient(..., middleware=[PurviewChatPolicyMiddleware(credential, settings)])
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
credential: TokenCredential | AsyncTokenCredential,
|
||||
credential: AzureCredentialTypes | AzureTokenProvider,
|
||||
settings: PurviewSettings,
|
||||
cache_provider: CacheProvider | None = None,
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user