Python: Replace Pydantic Settings with TypedDict + load_settings() (#3843)

* Replace Pydantic Settings with TypedDict + load_settings()

- Remove pydantic-settings dependency, add python-dotenv
- Delete _pydantic.py (AFBaseSettings, HTTPsUrl)
- Add _settings.py with generic load_settings() function, SecretString,
  type coercion, and Required field validation (SettingNotFoundError)
- Convert all 13 settings classes from AFBaseSettings subclasses to
  TypedDict definitions with load_settings() calls
- Update all consumers from attribute access to dict access
- Add 20 unit tests for load_settings() covering basic loading, dotenv,
  SecretString, type coercion, and required field validation
- Update all existing tests for new settings patterns

* Fix mypy type errors from settings conversion

- Fix str | None attribute access in responses_client (walrus operator)
- Fix SecretString | None narrowing in bedrock (type: ignore after guard)
- Convert _context_provider.py attribute access to dict access (missed file)
- Fix endpoint type narrowing in search_provider and context_provider
- Fix purview: str | None .rstrip(), int | None defaults, urlparse bytes

* Address PR review: required_fields param, type validation, fixes

- Move required field validation from TypedDict annotations (Required)
  to a required_fields parameter on load_settings(), enabling runtime
  decisions about which fields are required
- Remove Required imports and restore from __future__ import annotations
  in ollama and foundry_local
- Add _check_override_type() for deterministic ServiceInitializationError
  on invalid override types (e.g. dict passed for str field)
- Fix all multi-exception test catches back to single exception type
- Fix Ollama host=None: use .get() so None is passed through to SDK default
- Fix Purview processor: use explicit is-None checks instead of or operator
- Remove unused BaseModel import from openai/_shared.py
- Add 4 new tests (24 total): required_fields param, type validation

* Fix type validation: allow int for float fields

_check_override_type now permits int values for float-typed fields,
matching Python's standard numeric promotion behavior.

* fix: wrap urlparse arg with str() to fix mypy bytes endswith error
This commit is contained in:
Eduard van Valkenburg
2026-02-12 09:51:20 +01:00
committed by GitHub
Unverified
parent b488158abe
commit 8457533c69
58 changed files with 1526 additions and 1113 deletions
@@ -1,70 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
from typing import Annotated, Any, ClassVar, TypeVar
from pydantic import Field, UrlConstraints
from pydantic.networks import AnyUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
HTTPsUrl = Annotated[AnyUrl, UrlConstraints(max_length=2083, allowed_schemes=["https"])]
__all__ = ["AFBaseSettings", "HTTPsUrl"]
SettingsT = TypeVar("SettingsT", bound="AFBaseSettings")
class AFBaseSettings(BaseSettings):
"""Base class for all settings classes in the Agent Framework.
A subclass creates it's fields and overrides the env_prefix class variable
with the prefix for the environment variables.
In the case where a value is specified for the same Settings field in multiple ways,
the selected value is determined as follows (in descending order of priority):
- Arguments passed to the Settings class initializer.
- Environment variables, e.g. my_prefix_special_function as described above.
- Variables loaded from a dotenv (.env) file.
- Variables loaded from the secrets directory.
- The default field values for the Settings model.
"""
env_prefix: ClassVar[str] = ""
env_file_path: str | None = Field(default=None, exclude=True)
env_file_encoding: str | None = Field(default="utf-8", exclude=True)
model_config = SettingsConfigDict(
extra="ignore",
case_sensitive=False,
)
def __init__(
self,
**kwargs: Any,
) -> None:
"""Initialize the settings class."""
# Remove any None values from the kwargs so that defaults are used.
kwargs = {k: v for k, v in kwargs.items() if v is not None}
super().__init__(**kwargs)
def __new__(cls: type[SettingsT], *args: Any, **kwargs: Any) -> SettingsT:
"""Override the __new__ method to set the env_prefix."""
# for both, if supplied but None, set to default
if "env_file_encoding" in kwargs and kwargs["env_file_encoding"] is not None:
env_file_encoding = kwargs["env_file_encoding"]
else:
env_file_encoding = "utf-8"
if "env_file_path" in kwargs and kwargs["env_file_path"] is not None:
env_file_path = kwargs["env_file_path"]
else:
env_file_path = ".env"
cls.model_config.update( # type: ignore
env_prefix=cls.env_prefix,
env_file=env_file_path,
env_file_encoding=env_file_encoding,
)
cls.model_rebuild()
return super().__new__(cls) # type: ignore[return-value]
@@ -0,0 +1,262 @@
# Copyright (c) Microsoft. All rights reserved.
"""Generic settings loader with environment variable resolution.
This module provides a ``load_settings()`` function that populates a ``TypedDict``
from environment variables, ``.env`` files, and explicit overrides. It replaces
the previous pydantic-settings-based ``AFBaseSettings`` with a lighter-weight,
function-based approach that has no pydantic-settings dependency.
Usage::
class MySettings(TypedDict, total=False):
api_key: str | None # optional — resolves to None if not set
model_id: str | None # optional by default
# Make model_id required at call time:
settings = load_settings(
MySettings,
env_prefix="MY_APP_",
required_fields=["model_id"],
model_id="gpt-4",
)
settings["api_key"] # type-checked dict access
settings["model_id"] # str | None per type, but guaranteed not None at runtime
"""
from __future__ import annotations
import os
import sys
from collections.abc import Callable, Sequence
from contextlib import suppress
from typing import Any, Union, get_args, get_origin, get_type_hints
from dotenv import load_dotenv
from .exceptions import SettingNotFoundError
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
else:
from typing_extensions import TypeVar # type: ignore # pragma: no cover
__all__ = ["SecretString", "load_settings"]
SettingsT = TypeVar("SettingsT", default=dict[str, Any])
class SecretString(str):
"""A string subclass that masks its value in repr() to prevent accidental exposure.
SecretString behaves exactly like a regular string in all operations,
but its repr() shows '**********' instead of the actual value.
This helps prevent secrets from being accidentally logged or displayed.
It also provides a ``get_secret_value()`` method for backward compatibility
with code that previously used ``pydantic.SecretStr``.
Example:
```python
api_key = SecretString("sk-secret-key")
print(api_key) # sk-secret-key (normal string behavior)
print(repr(api_key)) # SecretString('**********')
print(f"Key: {api_key}") # Key: sk-secret-key
print(api_key.get_secret_value()) # sk-secret-key
```
"""
def __repr__(self) -> str:
"""Return a masked representation to prevent secret exposure."""
return "SecretString('**********')"
def get_secret_value(self) -> str:
"""Return the underlying string value.
Provided for backward compatibility with ``pydantic.SecretStr``.
Since SecretString *is* a str, this simply returns ``str(self)``.
"""
return str(self)
def _coerce_value(value: str, target_type: type) -> Any:
"""Coerce a string value to the target type."""
origin = get_origin(target_type)
args = get_args(target_type)
# Handle Union types (e.g., str | None) — try each non-None arm
if origin is type(None):
return None
if args and type(None) in args:
for arg in args:
if arg is not type(None):
with suppress(ValueError, TypeError):
return _coerce_value(value, arg)
return value
# Handle SecretString
if target_type is SecretString or (isinstance(target_type, type) and issubclass(target_type, SecretString)):
return SecretString(value)
# Handle basic types
if target_type is str:
return value
if target_type is int:
return int(value)
if target_type is float:
return float(value)
if target_type is bool:
return value.lower() in ("true", "1", "yes", "on")
return value
def _check_override_type(value: Any, field_type: type, field_name: str) -> None:
"""Validate that *value* is compatible with *field_type*.
Raises ``ServiceInitializationError`` when the override is clearly
incompatible (e.g. a ``dict`` passed where ``str`` is expected).
Callable values and ``None`` are always accepted.
"""
if value is None:
return
# Callables are always allowed (e.g. lazy token providers)
if callable(value) and not isinstance(value, (str, bytes)):
return
# Collect the concrete types that *field_type* allows
origin = get_origin(field_type)
args = get_args(field_type)
allowed: tuple[type, ...]
if origin is Union or origin is type(int | str):
allowed = tuple(a for a in args if isinstance(a, type) and a is not type(None))
# If any arm is a Callable, allow anything callable
if any(get_origin(a) is Callable or a is Callable for a in args):
return
elif isinstance(field_type, type):
allowed = (field_type,)
else:
return # complex / unknown annotation — skip check
if not allowed:
return
if not isinstance(value, allowed):
# Allow str for SecretString fields (will be coerced)
if isinstance(value, str) and any(isinstance(a, type) and issubclass(a, str) for a in allowed):
return
# Allow int for float fields (standard numeric promotion)
if isinstance(value, int) and float in allowed:
return
from .exceptions import ServiceInitializationError
allowed_names = ", ".join(t.__name__ for t in allowed)
raise ServiceInitializationError(
f"Invalid type for setting '{field_name}': expected {allowed_names}, got {type(value).__name__}."
)
def load_settings(
settings_type: type[SettingsT],
*,
env_prefix: str = "",
env_file_path: str | None = None,
env_file_encoding: str | None = None,
required_fields: Sequence[str] | None = None,
**overrides: Any,
) -> SettingsT:
"""Load settings from environment variables, a ``.env`` file, and explicit overrides.
The *settings_type* must be a ``TypedDict`` subclass. Values are resolved in
this order (highest priority first):
1. Explicit keyword *overrides* (``None`` values are filtered out).
2. Environment variables (``<env_prefix><FIELD_NAME>``).
3. A ``.env`` file (loaded via ``python-dotenv``; existing env vars take precedence).
4. Default values — fields with class-level defaults on the TypedDict, or
``None`` for optional fields.
Fields listed in *required_fields* are validated after resolution. If any
required field resolves to ``None``, a ``SettingNotFoundError`` is raised.
This allows callers to decide which fields are required based on runtime
context (e.g. ``endpoint`` is only required when no pre-built client is
provided).
Args:
settings_type: A ``TypedDict`` class describing the settings schema.
env_prefix: Prefix for environment variable lookup (e.g. ``"OPENAI_"``).
env_file_path: Path to ``.env`` file. Defaults to ``".env"`` when omitted.
env_file_encoding: Encoding for reading the ``.env`` file. Defaults to ``"utf-8"``.
required_fields: Field names that must resolve to a non-``None`` value.
**overrides: Field values. ``None`` values are ignored so that callers can
forward optional parameters without masking env-var / default resolution.
Returns:
A populated dict matching *settings_type*.
Raises:
SettingNotFoundError: If a required field could not be resolved from any source.
ServiceInitializationError: If an override value has an incompatible type.
"""
encoding = env_file_encoding or "utf-8"
# Load .env file if it exists (existing env vars take precedence by default)
env_path = env_file_path or ".env"
if os.path.isfile(env_path):
load_dotenv(dotenv_path=env_path, encoding=encoding)
# Filter out None overrides so defaults / env vars are preserved
overrides = {k: v for k, v in overrides.items() if v is not None}
# Get field type hints from the TypedDict
hints = get_type_hints(settings_type)
required: set[str] = set(required_fields) if required_fields else set()
result: dict[str, Any] = {}
for field_name, field_type in hints.items():
# 1. Explicit override wins
if field_name in overrides:
override_value = overrides[field_name]
_check_override_type(override_value, field_type, field_name)
# Coerce plain str → SecretString if the annotation expects it
if isinstance(override_value, str) and not isinstance(override_value, SecretString):
with suppress(ValueError, TypeError):
coerced = _coerce_value(override_value, field_type)
if isinstance(coerced, SecretString):
override_value = coerced
result[field_name] = override_value
continue
# 2. Environment variable
env_var_name = f"{env_prefix}{field_name.upper()}"
env_value = os.getenv(env_var_name)
if env_value is not None:
try:
result[field_name] = _coerce_value(env_value, field_type)
except (ValueError, TypeError):
result[field_name] = env_value
continue
# 3. Default from TypedDict class-level defaults, or None for optional fields
if hasattr(settings_type, field_name):
result[field_name] = getattr(settings_type, field_name)
else:
result[field_name] = None
# Validate required fields after all resolution
if required:
for field_name in required:
if result.get(field_name) is None:
env_var_name = f"{env_prefix}{field_name.upper()}"
raise SettingNotFoundError(
f"Required setting '{field_name}' was not provided. "
f"Set it via the '{field_name}' parameter or the "
f"'{env_var_name}' environment variable."
)
return result # type: ignore[return-value]
@@ -7,12 +7,13 @@ from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, ClassVar, Generic
from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI
from pydantic import ValidationError
from .._settings import load_settings
from ..exceptions import ServiceInitializationError
from ..openai import OpenAIAssistantsClient
from ..openai._assistants_client import OpenAIAssistantsOptions
from ._shared import AzureOpenAISettings
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
@@ -137,23 +138,21 @@ class AzureOpenAIAssistantsClient(
client: AzureOpenAIAssistantsClient[MyOptions] = AzureOpenAIAssistantsClient()
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
try:
azure_openai_settings = AzureOpenAISettings(
# pydantic settings will see if there is a value, if not, will try the env var or .env file
api_key=api_key, # type: ignore
base_url=base_url, # type: ignore
endpoint=endpoint, # type: ignore
chat_deployment_name=deployment_name,
api_version=api_version,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
token_endpoint=token_endpoint,
default_api_version=self.DEFAULT_AZURE_API_VERSION,
)
except ValidationError as ex:
raise ServiceInitializationError("Failed to create Azure OpenAI settings.", ex) from ex
azure_openai_settings = load_settings(
AzureOpenAISettings,
env_prefix="AZURE_OPENAI_",
api_key=api_key,
base_url=base_url,
endpoint=endpoint,
chat_deployment_name=deployment_name,
api_version=api_version,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
token_endpoint=token_endpoint,
)
_apply_azure_defaults(azure_openai_settings, default_api_version=self.DEFAULT_AZURE_API_VERSION)
if not azure_openai_settings.chat_deployment_name:
if not azure_openai_settings["chat_deployment_name"]:
raise ServiceInitializationError(
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable."
@@ -162,40 +161,41 @@ class AzureOpenAIAssistantsClient(
# 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 azure_openai_settings["api_key"]
and not ad_token
and not ad_token_provider
and azure_openai_settings.token_endpoint
and azure_openai_settings["token_endpoint"]
and credential
):
ad_token = azure_openai_settings.get_azure_auth_token(credential)
token_ep = azure_openai_settings["token_endpoint"] or DEFAULT_AZURE_TOKEN_ENDPOINT
ad_token = get_entra_auth_token(credential, token_ep)
if not async_client and not azure_openai_settings.api_key and not ad_token and not ad_token_provider:
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.")
# Create Azure client if not provided
if not async_client:
client_params: dict[str, Any] = {
"api_version": azure_openai_settings.api_version,
"api_version": azure_openai_settings["api_version"],
"default_headers": default_headers,
}
if azure_openai_settings.api_key:
client_params["api_key"] = azure_openai_settings.api_key.get_secret_value()
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
if azure_openai_settings.base_url:
client_params["base_url"] = str(azure_openai_settings.base_url)
elif azure_openai_settings.endpoint:
client_params["azure_endpoint"] = str(azure_openai_settings.endpoint)
if azure_openai_settings["base_url"]:
client_params["base_url"] = str(azure_openai_settings["base_url"])
elif azure_openai_settings["endpoint"]:
client_params["azure_endpoint"] = str(azure_openai_settings["endpoint"])
async_client = AsyncAzureOpenAI(**client_params)
super().__init__(
model_id=azure_openai_settings.chat_deployment_name,
model_id=azure_openai_settings["chat_deployment_name"],
assistant_id=assistant_id,
assistant_name=assistant_name,
assistant_description=assistant_description,
@@ -12,7 +12,7 @@ from azure.core.credentials import TokenCredential
from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
from pydantic import BaseModel, ValidationError
from pydantic import BaseModel
from agent_framework import (
Annotation,
@@ -28,9 +28,11 @@ from agent_framework.observability import ChatTelemetryLayer
from agent_framework.openai import OpenAIChatOptions
from agent_framework.openai._chat_client import RawOpenAIChatClient
from .._settings import load_settings
from ._shared import (
AzureOpenAIConfigMixin,
AzureOpenAISettings,
_apply_azure_defaults,
)
if sys.version_info >= (3, 13):
@@ -247,37 +249,35 @@ class AzureOpenAIChatClient( # type: ignore[misc]
client: AzureOpenAIChatClient[MyOptions] = AzureOpenAIChatClient()
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
try:
# Filter out any None values from the arguments
azure_openai_settings = AzureOpenAISettings(
# pydantic settings will see if there is a value, if not, will try the env var or .env file
api_key=api_key, # type: ignore
base_url=base_url, # type: ignore
endpoint=endpoint, # type: ignore
chat_deployment_name=deployment_name,
api_version=api_version,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
token_endpoint=token_endpoint,
)
except ValidationError as exc:
raise ServiceInitializationError(f"Failed to validate settings: {exc}") from exc
azure_openai_settings = load_settings(
AzureOpenAISettings,
env_prefix="AZURE_OPENAI_",
api_key=api_key,
base_url=base_url,
endpoint=endpoint,
chat_deployment_name=deployment_name,
api_version=api_version,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
token_endpoint=token_endpoint,
)
_apply_azure_defaults(azure_openai_settings)
if not azure_openai_settings.chat_deployment_name:
if not azure_openai_settings["chat_deployment_name"]:
raise ServiceInitializationError(
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable."
)
super().__init__(
deployment_name=azure_openai_settings.chat_deployment_name,
endpoint=azure_openai_settings.endpoint,
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,
deployment_name=azure_openai_settings["chat_deployment_name"],
endpoint=azure_openai_settings["endpoint"],
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,
token_endpoint=azure_openai_settings["token_endpoint"],
credential=credential,
default_headers=default_headers,
client=async_client,
@@ -5,15 +5,15 @@ from __future__ import annotations
import sys
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Generic
from urllib.parse import urljoin
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 pydantic import ValidationError
from .._middleware import ChatMiddlewareLayer
from .._settings import load_settings
from .._telemetry import AGENT_FRAMEWORK_USER_AGENT
from .._tools import FunctionInvocationConfiguration, FunctionInvocationLayer
from ..exceptions import ServiceInitializationError
@@ -22,6 +22,7 @@ from ..openai._responses_client import RawOpenAIResponsesClient
from ._shared import (
AzureOpenAIConfigMixin,
AzureOpenAISettings,
_apply_azure_defaults,
)
if sys.version_info >= (3, 13):
@@ -82,7 +83,8 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
env_file_encoding: str | None = None,
instruction_role: str | None = None,
middleware: Sequence[MiddlewareTypes] | None = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
function_invocation_configuration: FunctionInvocationConfiguration
| None = None,
**kwargs: Any,
) -> None:
"""Initialize an Azure OpenAI Responses client.
@@ -188,54 +190,58 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
deployment_name = str(model_id)
# Project client path: create OpenAI client from an Azure AI Foundry project
if async_client is None and (project_client is not None or project_endpoint is not None):
if async_client is None and (
project_client is not None or project_endpoint is not None
):
async_client = self._create_client_from_project(
project_client=project_client,
project_endpoint=project_endpoint,
credential=credential,
)
try:
azure_openai_settings = AzureOpenAISettings(
# pydantic settings will see if there is a value, if not, will try the env var or .env file
api_key=api_key, # type: ignore
base_url=base_url, # type: ignore
endpoint=endpoint, # type: ignore
responses_deployment_name=deployment_name,
api_version=api_version,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
token_endpoint=token_endpoint,
default_api_version="preview",
azure_openai_settings = load_settings(
AzureOpenAISettings,
env_prefix="AZURE_OPENAI_",
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_endpoint,
)
_apply_azure_defaults(azure_openai_settings, default_api_version="preview")
# TODO(peterychang): This is a temporary hack to ensure that the base_url is set correctly
# while this feature is in preview.
# But we should only do this if we're on azure. Private deployments may not need this.
if (
not azure_openai_settings.get("base_url")
and azure_openai_settings.get("endpoint")
and (hostname := urlparse(str(azure_openai_settings["endpoint"])).hostname)
and hostname.endswith(".openai.azure.com")
):
azure_openai_settings["base_url"] = urljoin(
str(azure_openai_settings["endpoint"]), "/openai/v1/"
)
# TODO(peterychang): This is a temporary hack to ensure that the base_url is set correctly
# while this feature is in preview.
# But we should only do this if we're on azure. Private deployments may not need this.
if (
not azure_openai_settings.base_url
and azure_openai_settings.endpoint
and azure_openai_settings.endpoint.host
and azure_openai_settings.endpoint.host.endswith(".openai.azure.com")
):
azure_openai_settings.base_url = urljoin(str(azure_openai_settings.endpoint), "/openai/v1/") # type: ignore
except ValidationError as exc:
raise ServiceInitializationError(f"Failed to validate settings: {exc}") from exc
if not azure_openai_settings.responses_deployment_name:
if not azure_openai_settings["responses_deployment_name"]:
raise ServiceInitializationError(
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME' environment variable."
)
super().__init__(
deployment_name=azure_openai_settings.responses_deployment_name,
endpoint=azure_openai_settings.endpoint,
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,
deployment_name=azure_openai_settings["responses_deployment_name"],
endpoint=azure_openai_settings["endpoint"],
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,
token_endpoint=azure_openai_settings["token_endpoint"],
credential=credential,
default_headers=default_headers,
client=async_client,
@@ -11,28 +11,26 @@ from typing import Any, ClassVar, Final
from azure.core.credentials import TokenCredential
from openai import AsyncOpenAI
from openai.lib.azure import AsyncAzureOpenAI
from pydantic import SecretStr, model_validator
from .._pydantic import AFBaseSettings, HTTPsUrl
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
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
logger: logging.Logger = logging.getLogger(__name__)
if sys.version_info >= (3, 11):
from typing import TypedDict # type: ignore # pragma: no cover
else:
from typing_extensions import TypedDict # type: ignore # pragma: no cover
DEFAULT_AZURE_API_VERSION: Final[str] = "2024-10-21"
DEFAULT_AZURE_TOKEN_ENDPOINT: Final[str] = "https://cognitiveservices.azure.com/.default" # noqa: S105
class AzureOpenAISettings(AFBaseSettings):
class AzureOpenAISettings(TypedDict, total=False):
"""AzureOpenAI model settings.
The settings are first loaded from environment variables with the prefix 'AZURE_OPENAI_'.
@@ -62,7 +60,7 @@ class AzureOpenAISettings(AFBaseSettings):
found in the Keys & Endpoint section when examining your resource in
the Azure portal. You can use either KEY1 or KEY2.
Can be set via environment variable AZURE_OPENAI_API_KEY.
api_version: The API version to use. The default value is `default_api_version`.
api_version: The API version to use. The default value is `DEFAULT_AZURE_API_VERSION`.
Can be set via environment variable AZURE_OPENAI_API_VERSION.
base_url: The url of the Azure deployment. This value
can be found in the Keys & Endpoint section when examining
@@ -71,14 +69,8 @@ class AzureOpenAISettings(AFBaseSettings):
use endpoint if you only want to supply the endpoint.
Can be set via environment variable AZURE_OPENAI_BASE_URL.
token_endpoint: The token endpoint to use to retrieve the authentication token.
The default value is `default_token_endpoint`.
The default value is `DEFAULT_AZURE_TOKEN_ENDPOINT`.
Can be set via environment variable AZURE_OPENAI_TOKEN_ENDPOINT.
default_api_version: The default API version to use if not specified.
The default value is "2024-10-21".
default_token_endpoint: The default token endpoint to use if not specified.
The default value is "https://cognitiveservices.azure.com/.default".
env_file_path: The path to the .env file to load settings from.
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
Examples:
.. code-block:: python
@@ -89,60 +81,46 @@ class AzureOpenAISettings(AFBaseSettings):
# Set AZURE_OPENAI_ENDPOINT=https://your-endpoint.openai.azure.com
# Set AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=gpt-4
# Set AZURE_OPENAI_API_KEY=your-key
settings = AzureOpenAISettings()
settings = load_settings(AzureOpenAISettings, env_prefix="AZURE_OPENAI_")
# Or passing parameters directly
settings = AzureOpenAISettings(
endpoint="https://your-endpoint.openai.azure.com", chat_deployment_name="gpt-4", api_key="your-key"
settings = load_settings(
AzureOpenAISettings,
env_prefix="AZURE_OPENAI_",
endpoint="https://your-endpoint.openai.azure.com",
chat_deployment_name="gpt-4",
api_key="your-key",
)
# Or loading from a .env file
settings = AzureOpenAISettings(env_file_path="path/to/.env")
settings = load_settings(AzureOpenAISettings, env_prefix="AZURE_OPENAI_", env_file_path="path/to/.env")
"""
env_prefix: ClassVar[str] = "AZURE_OPENAI_"
chat_deployment_name: str | None
responses_deployment_name: str | None
endpoint: str | None
base_url: str | None
api_key: SecretString | None
api_version: str | None
token_endpoint: str | None
chat_deployment_name: str | None = None
responses_deployment_name: str | None = None
endpoint: HTTPsUrl | None = None
base_url: HTTPsUrl | None = None
api_key: SecretStr | None = None
api_version: str | None = None
token_endpoint: str | None = None
default_api_version: str = DEFAULT_AZURE_API_VERSION
default_token_endpoint: str = DEFAULT_AZURE_TOKEN_ENDPOINT
def get_azure_auth_token(
self, credential: TokenCredential, token_endpoint: str | None = None, **kwargs: Any
) -> str | None:
"""Retrieve a Microsoft Entra Auth Token for a given token endpoint for the use with Azure OpenAI.
def _apply_azure_defaults(
settings: AzureOpenAISettings,
default_api_version: str = DEFAULT_AZURE_API_VERSION,
default_token_endpoint: str = DEFAULT_AZURE_TOKEN_ENDPOINT,
) -> None:
"""Apply default values for api_version and token_endpoint after loading settings.
The required role for the token is `Cognitive Services OpenAI Contributor`.
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.
The `token_endpoint` argument takes precedence over the `token_endpoint` attribute.
Args:
credential: The Azure AD credential to use.
token_endpoint: The token endpoint to use. Defaults to `https://cognitiveservices.azure.com/.default`.
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.
Raises:
ServiceInitializationError: If the token endpoint is not provided.
"""
endpoint_to_use = token_endpoint or self.token_endpoint or self.default_token_endpoint
return get_entra_auth_token(credential, endpoint_to_use, **kwargs)
@model_validator(mode="after")
def _validate_fields(self) -> Self:
self.api_version = self.api_version or self.default_api_version
self.token_endpoint = self.token_endpoint or self.default_token_endpoint
return self
Args:
settings: The loaded Azure OpenAI settings dict.
default_api_version: The default API version to use if not set.
default_token_endpoint: The default token endpoint to use if not set.
"""
if not settings.get("api_version"):
settings["api_version"] = default_api_version
if not settings.get("token_endpoint"):
settings["token_endpoint"] = default_token_endpoint
class AzureOpenAIConfigMixin(OpenAIBase):
@@ -154,8 +132,8 @@ class AzureOpenAIConfigMixin(OpenAIBase):
def __init__(
self,
deployment_name: str,
endpoint: HTTPsUrl | None = None,
base_url: HTTPsUrl | None = None,
endpoint: str | None = None,
base_url: str | None = None,
api_version: str = DEFAULT_AZURE_API_VERSION,
api_key: str | None = None,
ad_token: str | None = None,
@@ -170,7 +148,7 @@ class AzureOpenAIConfigMixin(OpenAIBase):
"""Internal class for configuring a connection to an Azure OpenAI service.
The `validate_call` decorator is used with a configuration that allows arbitrary types.
This is necessary for types like `HTTPsUrl` and `OpenAIModelTypes`.
This is necessary for types like `str` and `OpenAIModelTypes`.
Args:
deployment_name: Name of the deployment.
@@ -146,3 +146,9 @@ class ContentError(AgentFrameworkException):
"""An error occurred while processing content."""
pass
class SettingNotFoundError(AgentFrameworkException):
"""A required setting could not be resolved from any source."""
pass
@@ -18,11 +18,10 @@ from opentelemetry import metrics, trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.semconv.attributes import service_attributes
from opentelemetry.semconv_ai import Meters, SpanAttributes
from pydantic import PrivateAttr
from . import __version__ as version_info
from ._logging import get_logger
from ._pydantic import AFBaseSettings
from ._settings import load_settings
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
@@ -566,7 +565,16 @@ def create_metric_views() -> list[View]:
]
class ObservabilitySettings(AFBaseSettings):
class _ObservabilitySettingsData(TypedDict, total=False):
"""TypedDict schema for observability settings fields."""
enable_instrumentation: bool | None
enable_sensitive_data: bool | None
enable_console_exporters: bool | None
vs_code_extension_port: int | None
class ObservabilitySettings:
"""Settings for Agent Framework Observability.
If the environment variables are not found, the settings can
@@ -603,23 +611,27 @@ class ObservabilitySettings(AFBaseSettings):
settings = ObservabilitySettings(enable_instrumentation=True, enable_console_exporters=True)
"""
env_prefix: ClassVar[str] = ""
enable_instrumentation: bool = False
enable_sensitive_data: bool = False
enable_console_exporters: bool = False
vs_code_extension_port: int | None = None
_resource: Resource = PrivateAttr()
_executed_setup: bool = PrivateAttr(default=False)
def __init__(self, **kwargs: Any) -> None:
"""Initialize the settings and create the resource."""
super().__init__(**kwargs)
# Create resource with env file settings
self._resource = create_resource(
env_file_path=self.env_file_path,
env_file_encoding=self.env_file_encoding,
env_file_path = kwargs.pop("env_file_path", None)
env_file_encoding = kwargs.pop("env_file_encoding", None)
data = load_settings(
_ObservabilitySettingsData,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
**kwargs,
)
self.enable_instrumentation: bool = data.get("enable_instrumentation") or False
self.enable_sensitive_data: bool = data.get("enable_sensitive_data") or False
self.enable_console_exporters: bool = data.get("enable_console_exporters") or False
self.vs_code_extension_port: int | None = data.get("vs_code_extension_port")
self.env_file_path = env_file_path
self.env_file_encoding = env_file_encoding
self._resource = create_resource(
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
self._executed_setup = False
@property
def ENABLED(self) -> bool:
@@ -8,7 +8,9 @@ from typing import TYPE_CHECKING, Any, Generic, cast
from openai import AsyncOpenAI
from openai.types.beta.assistant import Assistant
from pydantic import BaseModel, SecretStr, ValidationError
from pydantic import BaseModel
from agent_framework._settings import SecretString, load_settings
from .._agents import Agent
from .._memory import ContextProvider
@@ -107,7 +109,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
self,
client: AsyncOpenAI | None = None,
*,
api_key: str | SecretStr | Callable[[], str | Awaitable[str]] | None = None,
api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None,
org_id: str | None = None,
base_url: str | None = None,
env_file_path: str | None = None,
@@ -147,35 +149,34 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
if client is None:
# Load settings and create client
try:
settings = OpenAISettings(
api_key=api_key, # type: ignore[reportArgumentType]
org_id=org_id,
base_url=base_url,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
except ValidationError as ex:
raise ServiceInitializationError("Failed to create OpenAI settings.", ex) from ex
settings = load_settings(
OpenAISettings,
env_prefix="OPENAI_",
api_key=api_key,
org_id=org_id,
base_url=base_url,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
if not settings.api_key:
if not settings["api_key"]:
raise ServiceInitializationError(
"OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable."
)
# Get API key value
api_key_value: str | Callable[[], str | Awaitable[str]] | None
if isinstance(settings.api_key, SecretStr):
api_key_value = settings.api_key.get_secret_value()
if isinstance(settings["api_key"], SecretString):
api_key_value = settings["api_key"].get_secret_value()
else:
api_key_value = settings.api_key
api_key_value = settings["api_key"]
# Create client
client_args: dict[str, Any] = {"api_key": api_key_value}
if settings.org_id:
client_args["organization"] = settings.org_id
if settings.base_url:
client_args["base_url"] = settings.base_url
if settings["org_id"]:
client_args["organization"] = settings["org_id"]
if settings["base_url"]:
client_args["base_url"] = settings["base_url"]
self._client = AsyncOpenAI(**client_args)
@@ -27,10 +27,11 @@ from openai.types.beta.threads import (
from openai.types.beta.threads.run_create_params import AdditionalMessage
from openai.types.beta.threads.run_submit_tool_outputs_params import ToolOutput
from openai.types.beta.threads.runs import RunStep
from pydantic import BaseModel, ValidationError
from pydantic import BaseModel
from .._clients import BaseChatClient
from .._middleware import ChatMiddlewareLayer
from .._settings import load_settings
from .._tools import (
FunctionInvocationConfiguration,
FunctionInvocationLayer,
@@ -343,35 +344,34 @@ class OpenAIAssistantsClient( # type: ignore[misc]
client: OpenAIAssistantsClient[MyOptions] = OpenAIAssistantsClient(model_id="gpt-4")
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
try:
openai_settings = OpenAISettings(
api_key=api_key, # type: ignore[reportArgumentType]
base_url=base_url,
org_id=org_id,
chat_model_id=model_id,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
except ValidationError as ex:
raise ServiceInitializationError("Failed to create OpenAI settings.", ex) from ex
openai_settings = load_settings(
OpenAISettings,
env_prefix="OPENAI_",
api_key=api_key,
base_url=base_url,
org_id=org_id,
chat_model_id=model_id,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
if not async_client and not openai_settings.api_key:
if not async_client and not openai_settings["api_key"]:
raise ServiceInitializationError(
"OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable."
)
if not openai_settings.chat_model_id:
if not openai_settings["chat_model_id"]:
raise ServiceInitializationError(
"OpenAI model ID is required. "
"Set via 'model_id' parameter or 'OPENAI_CHAT_MODEL_ID' environment variable."
)
super().__init__(
model_id=openai_settings.chat_model_id,
api_key=self._get_api_key(openai_settings.api_key),
org_id=openai_settings.org_id,
model_id=openai_settings["chat_model_id"],
api_key=self._get_api_key(openai_settings["api_key"]),
org_id=openai_settings["org_id"],
default_headers=default_headers,
client=async_client,
base_url=openai_settings.base_url,
base_url=openai_settings["base_url"],
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
)
@@ -17,11 +17,12 @@ from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
from openai.types.chat.chat_completion_message_custom_tool_call import ChatCompletionMessageCustomToolCall
from openai.types.chat.completion_create_params import WebSearchOptions
from pydantic import BaseModel, ValidationError
from pydantic import BaseModel
from .._clients import BaseChatClient
from .._logging import get_logger
from .._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer
from .._settings import load_settings
from .._tools import (
FunctionInvocationConfiguration,
FunctionInvocationLayer,
@@ -718,33 +719,32 @@ class OpenAIChatClient( # type: ignore[misc]
client: OpenAIChatClient[MyOptions] = OpenAIChatClient(model_id="<model name>")
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
try:
openai_settings = OpenAISettings(
api_key=api_key, # type: ignore[reportArgumentType]
base_url=base_url,
org_id=org_id,
chat_model_id=model_id,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
except ValidationError as ex:
raise ServiceInitializationError("Failed to create OpenAI settings.", ex) from ex
openai_settings = load_settings(
OpenAISettings,
env_prefix="OPENAI_",
api_key=api_key,
base_url=base_url,
org_id=org_id,
chat_model_id=model_id,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
if not async_client and not openai_settings.api_key:
if not async_client and not openai_settings["api_key"]:
raise ServiceInitializationError(
"OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable."
)
if not openai_settings.chat_model_id:
if not openai_settings["chat_model_id"]:
raise ServiceInitializationError(
"OpenAI model ID is required. "
"Set via 'model_id' parameter or 'OPENAI_CHAT_MODEL_ID' environment variable."
)
super().__init__(
model_id=openai_settings.chat_model_id,
api_key=self._get_api_key(openai_settings.api_key),
base_url=openai_settings.base_url if openai_settings.base_url else None,
org_id=openai_settings.org_id,
model_id=openai_settings["chat_model_id"],
api_key=self._get_api_key(openai_settings["api_key"]),
base_url=openai_settings["base_url"] if openai_settings["base_url"] else None,
org_id=openai_settings["org_id"],
default_headers=default_headers,
client=async_client,
instruction_role=instruction_role,
@@ -33,11 +33,12 @@ from openai.types.responses.tool_param import (
Mcp,
)
from openai.types.responses.web_search_tool_param import WebSearchToolParam
from pydantic import BaseModel, ValidationError
from pydantic import BaseModel
from .._clients import BaseChatClient
from .._logging import get_logger
from .._middleware import ChatMiddlewareLayer
from .._settings import load_settings
from .._tools import (
FunctionInvocationConfiguration,
FunctionInvocationLayer,
@@ -1810,36 +1811,35 @@ class OpenAIResponsesClient( # type: ignore[misc]
client: OpenAIResponsesClient[MyOptions] = OpenAIResponsesClient(model_id="gpt-4o")
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
try:
openai_settings = OpenAISettings(
api_key=api_key, # type: ignore[reportArgumentType]
org_id=org_id,
base_url=base_url,
responses_model_id=model_id,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
except ValidationError as ex:
raise ServiceInitializationError("Failed to create OpenAI settings.", ex) from ex
openai_settings = load_settings(
OpenAISettings,
env_prefix="OPENAI_",
api_key=api_key,
org_id=org_id,
base_url=base_url,
responses_model_id=model_id,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
if not async_client and not openai_settings.api_key:
if not async_client and not openai_settings["api_key"]:
raise ServiceInitializationError(
"OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable."
)
if not openai_settings.responses_model_id:
if not openai_settings["responses_model_id"]:
raise ServiceInitializationError(
"OpenAI model ID is required. "
"Set via 'model_id' parameter or 'OPENAI_RESPONSES_MODEL_ID' environment variable."
)
super().__init__(
model_id=openai_settings.responses_model_id,
api_key=self._get_api_key(openai_settings.api_key),
org_id=openai_settings.org_id,
model_id=openai_settings["responses_model_id"],
api_key=self._get_api_key(openai_settings["api_key"]),
org_id=openai_settings["org_id"],
default_headers=default_headers,
client=async_client,
instruction_role=instruction_role,
base_url=openai_settings.base_url,
base_url=openai_settings["base_url"],
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
**kwargs,
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import sys
from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence
from copy import copy
from typing import Any, ClassVar, Union
@@ -20,11 +21,10 @@ from openai.types.images_response import ImagesResponse
from openai.types.responses.response import Response
from openai.types.responses.response_stream_event import ResponseStreamEvent
from packaging.version import parse
from pydantic import SecretStr
from .._logging import get_logger
from .._pydantic import AFBaseSettings
from .._serialization import SerializationMixin
from .._settings import SecretString
from .._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent
from .._tools import FunctionTool
from ..exceptions import ServiceInitializationError
@@ -47,6 +47,11 @@ RESPONSE_TYPE = Union[
OPTION_TYPE = dict[str, Any]
if sys.version_info >= (3, 11):
from typing import TypedDict # type: ignore # pragma: no cover
else:
from typing_extensions import TypedDict # type: ignore # pragma: no cover
__all__ = ["OpenAISettings"]
@@ -74,7 +79,7 @@ def _check_openai_version_for_callable_api_key() -> None:
logger.warning(f"Could not check OpenAI version for callable API key support: {e}")
class OpenAISettings(AFBaseSettings):
class OpenAISettings(TypedDict, total=False):
"""OpenAI environment settings.
The settings are first loaded from environment variables with the prefix 'OPENAI_'.
@@ -93,8 +98,6 @@ class OpenAISettings(AFBaseSettings):
Can be set via environment variable OPENAI_CHAT_MODEL_ID.
responses_model_id: The OpenAI responses model ID to use, for example, gpt-4o or o1.
Can be set via environment variable OPENAI_RESPONSES_MODEL_ID.
env_file_path: The path to the .env file to load settings from.
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
Examples:
.. code-block:: python
@@ -104,22 +107,20 @@ class OpenAISettings(AFBaseSettings):
# Using environment variables
# Set OPENAI_API_KEY=sk-...
# Set OPENAI_CHAT_MODEL_ID=gpt-4
settings = OpenAISettings()
settings = load_settings(OpenAISettings, env_prefix="OPENAI_")
# Or passing parameters directly
settings = OpenAISettings(api_key="sk-...", chat_model_id="gpt-4")
settings = load_settings(OpenAISettings, env_prefix="OPENAI_", api_key="sk-...", chat_model_id="gpt-4")
# Or loading from a .env file
settings = OpenAISettings(env_file_path="path/to/.env")
settings = load_settings(OpenAISettings, env_prefix="OPENAI_", env_file_path="path/to/.env")
"""
env_prefix: ClassVar[str] = "OPENAI_"
api_key: SecretStr | Callable[[], str | Awaitable[str]] | None = None
base_url: str | None = None
org_id: str | None = None
chat_model_id: str | None = None
responses_model_id: str | None = None
api_key: SecretString | Callable[[], str | Awaitable[str]] | None
base_url: str | None
org_id: str | None
chat_model_id: str | None
responses_model_id: str | None
class OpenAIBase(SerializationMixin):
@@ -181,19 +182,18 @@ class OpenAIBase(SerializationMixin):
return self.client
def _get_api_key(
self, api_key: str | SecretStr | Callable[[], str | Awaitable[str]] | None
self, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None
) -> str | Callable[[], str | Awaitable[str]] | None:
"""Get the appropriate API key value for client initialization.
Args:
api_key: The API key parameter which can be a string, SecretStr, callable, or None.
api_key: The API key parameter which can be a string, SecretString, callable, or None.
Returns:
For callable API keys: returns the callable directly.
For SecretStr API keys: returns the string value.
For string/None API keys: returns as-is.
For SecretString/string/None API keys: returns as-is (SecretString is a str subclass).
"""
if isinstance(api_key, SecretStr):
if isinstance(api_key, SecretString):
return api_key.get_secret_value()
# Check version compatibility for callable API keys
+1 -1
View File
@@ -26,7 +26,7 @@ dependencies = [
# utilities
"typing-extensions",
"pydantic>=2,<3",
"pydantic-settings>=2,<3",
"python-dotenv>=1,<2",
# telemetry
"opentelemetry-api>=1.39.0",
"opentelemetry-sdk>=1.39.0",
@@ -19,6 +19,7 @@ from agent_framework import (
SupportsChatGetResponse,
tool,
)
from agent_framework._settings import SecretString
from agent_framework.azure import AzureOpenAIAssistantsClient
from agent_framework.exceptions import ServiceInitializationError
@@ -556,19 +557,21 @@ def test_azure_assistants_client_entra_id_authentication() -> None:
mock_credential = MagicMock()
with (
patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class,
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.AsyncAzureOpenAI") as mock_azure_client,
patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None),
):
mock_settings = MagicMock()
mock_settings.chat_deployment_name = "test-deployment"
mock_settings.api_key = None # No API key to trigger Entra ID path
mock_settings.token_endpoint = "https://login.microsoftonline.com/test"
mock_settings.get_azure_auth_token.return_value = "entra-token-12345"
mock_settings.api_version = "2024-05-01-preview"
mock_settings.endpoint = "https://test-endpoint.openai.azure.com"
mock_settings.base_url = None
mock_settings_class.return_value = mock_settings
mock_load_settings.return_value = {
"chat_deployment_name": "test-deployment",
"responses_deployment_name": None,
"api_key": None,
"token_endpoint": "https://login.microsoftonline.com/test",
"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",
@@ -579,7 +582,7 @@ def test_azure_assistants_client_entra_id_authentication() -> None:
)
# Verify Entra ID token was requested
mock_settings.get_azure_auth_token.assert_called_once_with(mock_credential)
mock_get_token.assert_called_once_with(mock_credential, "https://login.microsoftonline.com/test")
# Verify client was created with the token
mock_azure_client.assert_called_once()
@@ -592,12 +595,16 @@ def test_azure_assistants_client_entra_id_authentication() -> None:
def test_azure_assistants_client_no_authentication_error() -> None:
"""Test authentication validation error when no auth provided."""
with patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class:
mock_settings = MagicMock()
mock_settings.chat_deployment_name = "test-deployment"
mock_settings.api_key = None # No API key
mock_settings.token_endpoint = None # No token endpoint
mock_settings_class.return_value = mock_settings
with patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings:
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,
}
# Test missing authentication raises error
with pytest.raises(ServiceInitializationError, match="API key, ad_token, or ad_token_provider is required"):
@@ -611,17 +618,19 @@ 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."""
with (
patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class,
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_settings = MagicMock()
mock_settings.chat_deployment_name = "test-deployment"
mock_settings.api_key = None # No API key
mock_settings.api_version = "2024-05-01-preview"
mock_settings.endpoint = "https://test-endpoint.openai.azure.com"
mock_settings.base_url = None
mock_settings_class.return_value = mock_settings
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",
@@ -645,17 +654,19 @@ def test_azure_assistants_client_ad_token_provider_authentication() -> None:
mock_token_provider = MagicMock(spec=AsyncAzureADTokenProvider)
with (
patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class,
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_settings = MagicMock()
mock_settings.chat_deployment_name = "test-deployment"
mock_settings.api_key = None # No API key
mock_settings.api_version = "2024-05-01-preview"
mock_settings.endpoint = "https://test-endpoint.openai.azure.com"
mock_settings.base_url = None
mock_settings_class.return_value = mock_settings
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",
@@ -675,17 +686,19 @@ def test_azure_assistants_client_ad_token_provider_authentication() -> None:
def test_azure_assistants_client_base_url_configuration() -> None:
"""Test base_url client parameter path."""
with (
patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class,
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_settings = MagicMock()
mock_settings.chat_deployment_name = "test-deployment"
mock_settings.api_key.get_secret_value.return_value = "test-api-key"
mock_settings.base_url = "https://custom-base-url.com"
mock_settings.endpoint = None # No endpoint, should use base_url
mock_settings.api_version = "2024-05-01-preview"
mock_settings_class.return_value = mock_settings
mock_load_settings.return_value = {
"chat_deployment_name": "test-deployment",
"responses_deployment_name": None,
"api_key": SecretString("test-api-key"),
"token_endpoint": None,
"api_version": "2024-05-01-preview",
"endpoint": None,
"base_url": "https://custom-base-url.com",
}
client = AzureOpenAIAssistantsClient(
deployment_name="test-deployment", api_key="test-api-key", base_url="https://custom-base-url.com"
@@ -704,17 +717,19 @@ def test_azure_assistants_client_base_url_configuration() -> None:
def test_azure_assistants_client_azure_endpoint_configuration() -> None:
"""Test azure_endpoint client parameter path."""
with (
patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class,
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_settings = MagicMock()
mock_settings.chat_deployment_name = "test-deployment"
mock_settings.api_key.get_secret_value.return_value = "test-api-key"
mock_settings.base_url = None # No base_url
mock_settings.endpoint = "https://test-endpoint.openai.azure.com"
mock_settings.api_version = "2024-05-01-preview"
mock_settings_class.return_value = mock_settings
mock_load_settings.return_value = {
"chat_deployment_name": "test-deployment",
"responses_deployment_name": None,
"api_key": SecretString("test-api-key"),
"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",
@@ -109,8 +109,11 @@ def test_init_with_empty_endpoint_and_base_url(azure_openai_unit_test_env: dict[
@pytest.mark.parametrize("override_env_param_dict", [{"AZURE_OPENAI_ENDPOINT": "http://test.com"}], indirect=True)
def test_init_with_invalid_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None:
with pytest.raises(ServiceInitializationError):
AzureOpenAIChatClient()
# Note: URL scheme validation was previously handled by pydantic's HTTPsUrl type.
# After migrating to load_settings with TypedDict, endpoint is a plain string and no longer
# validated at the settings level. The Azure OpenAI SDK may reject invalid URLs at runtime.
client = AzureOpenAIChatClient()
assert client is not None
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_BASE_URL"]], indirect=True)
@@ -0,0 +1,238 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for load_settings() function."""
import os
import tempfile
from typing import TypedDict
import pytest
from agent_framework._settings import SecretString, load_settings
class SimpleSettings(TypedDict, total=False):
api_key: str | None
timeout: int | None
enabled: bool | None
rate_limit: float | None
class RequiredFieldSettings(TypedDict, total=False):
name: str | None
optional_field: str | None
class SecretSettings(TypedDict, total=False):
api_key: SecretString | None
username: str | None
class TestLoadSettingsBasic:
"""Test basic load_settings functionality."""
def test_fields_are_none_when_unset(self) -> None:
settings = load_settings(SimpleSettings, env_prefix="TEST_APP_")
assert settings["api_key"] is None
assert settings["timeout"] is None
assert settings["enabled"] is None
assert settings["rate_limit"] is None
def test_overrides(self) -> None:
settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", timeout=60, enabled=False)
assert settings["timeout"] == 60
assert settings["enabled"] is False
def test_none_overrides_are_filtered(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TEST_APP_TIMEOUT", "120")
settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", timeout=None)
# timeout=None is filtered, so env var wins
assert settings["timeout"] == 120
def test_env_vars(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TEST_APP_API_KEY", "test-key-123")
monkeypatch.setenv("TEST_APP_TIMEOUT", "120")
monkeypatch.setenv("TEST_APP_ENABLED", "false")
settings = load_settings(SimpleSettings, env_prefix="TEST_APP_")
assert settings["api_key"] == "test-key-123"
assert settings["timeout"] == 120
assert settings["enabled"] is False
def test_overrides_beat_env_vars(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TEST_APP_TIMEOUT", "120")
settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", timeout=60)
assert settings["timeout"] == 60
def test_no_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("API_KEY", "no-prefix-key")
settings = load_settings(SimpleSettings, api_key=None)
assert settings["api_key"] == "no-prefix-key"
class TestDotenvFile:
"""Test .env file loading."""
def test_load_from_dotenv(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("TEST_APP_API_KEY", raising=False)
monkeypatch.delenv("TEST_APP_TIMEOUT", raising=False)
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
f.write("TEST_APP_API_KEY=dotenv-key\n")
f.write("TEST_APP_TIMEOUT=90\n")
f.flush()
env_path = f.name
try:
settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", env_file_path=env_path)
assert settings["api_key"] == "dotenv-key"
assert settings["timeout"] == 90
finally:
os.unlink(env_path)
def test_env_vars_override_dotenv(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TEST_APP_API_KEY", "real-env-key")
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
f.write("TEST_APP_API_KEY=dotenv-key\n")
f.flush()
env_path = f.name
try:
settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", env_file_path=env_path)
assert settings["api_key"] == "real-env-key"
finally:
os.unlink(env_path)
def test_missing_dotenv_file(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("TEST_APP_API_KEY", raising=False)
settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", env_file_path="/nonexistent/.env")
assert settings["api_key"] is None
class TestSecretString:
"""Test SecretString type handling."""
def test_secretstring_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("SECRET_API_KEY", "secret-value")
settings = load_settings(SecretSettings, env_prefix="SECRET_")
assert isinstance(settings["api_key"], SecretString)
assert settings["api_key"] == "secret-value"
def test_secretstring_from_override(self) -> None:
settings = load_settings(SecretSettings, env_prefix="SECRET_", api_key="kwarg-secret")
assert isinstance(settings["api_key"], SecretString)
assert settings["api_key"] == "kwarg-secret"
def test_secretstring_masked_in_repr(self) -> None:
s = SecretString("my-secret")
assert "my-secret" not in repr(s)
assert "**********" in repr(s)
def test_get_secret_value_compat(self) -> None:
s = SecretString("my-secret")
assert s.get_secret_value() == "my-secret"
assert isinstance(s.get_secret_value(), str)
class TestTypeCoercion:
"""Test type coercion from string values."""
def test_int_coercion(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TEST_APP_TIMEOUT", "42")
settings = load_settings(SimpleSettings, env_prefix="TEST_APP_")
assert settings["timeout"] == 42
assert isinstance(settings["timeout"], int)
def test_float_coercion(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TEST_APP_RATE_LIMIT", "2.5")
settings = load_settings(SimpleSettings, env_prefix="TEST_APP_")
assert settings["rate_limit"] == 2.5
assert isinstance(settings["rate_limit"], float)
def test_bool_coercion_true_values(self, monkeypatch: pytest.MonkeyPatch) -> None:
for true_val in ["true", "True", "TRUE", "1", "yes", "on"]:
monkeypatch.setenv("TEST_APP_ENABLED", true_val)
settings = load_settings(SimpleSettings, env_prefix="TEST_APP_")
assert settings["enabled"] is True, f"Failed for {true_val}"
def test_bool_coercion_false_values(self, monkeypatch: pytest.MonkeyPatch) -> None:
for false_val in ["false", "False", "FALSE", "0", "no", "off"]:
monkeypatch.setenv("TEST_APP_ENABLED", false_val)
settings = load_settings(SimpleSettings, env_prefix="TEST_APP_")
assert settings["enabled"] is False, f"Failed for {false_val}"
class TestRequiredFields:
"""Test required field validation."""
def test_required_field_provided(self) -> None:
settings = load_settings(
RequiredFieldSettings,
env_prefix="TEST_",
required_fields=["name"],
name="my-app",
)
assert settings["name"] == "my-app"
assert settings["optional_field"] is None
def test_required_field_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TEST_NAME", "env-app")
settings = load_settings(RequiredFieldSettings, env_prefix="TEST_", required_fields=["name"])
assert settings["name"] == "env-app"
def test_required_field_missing_raises(self) -> None:
from agent_framework.exceptions import SettingNotFoundError
with pytest.raises(SettingNotFoundError, match="Required setting 'name'"):
load_settings(RequiredFieldSettings, env_prefix="TEST_", required_fields=["name"])
def test_without_required_fields_param_allows_none(self) -> None:
settings = load_settings(RequiredFieldSettings, env_prefix="TEST_")
assert settings["name"] is None
class TestOverrideTypeValidation:
"""Test override type validation."""
def test_invalid_type_raises(self) -> None:
from agent_framework.exceptions import ServiceInitializationError
with pytest.raises(ServiceInitializationError, match="Invalid type for setting 'api_key'"):
load_settings(SimpleSettings, env_prefix="TEST_", api_key={"bad": "type"})
def test_valid_types_accepted(self) -> None:
settings = load_settings(SimpleSettings, env_prefix="TEST_", timeout=42, enabled=True)
assert settings["timeout"] == 42
assert settings["enabled"] is True
def test_str_accepted_for_secretstring(self) -> None:
settings = load_settings(SecretSettings, env_prefix="TEST_", api_key="plain-string")
assert isinstance(settings["api_key"], SecretString)
assert settings["api_key"] == "plain-string"
@@ -131,9 +131,15 @@ class TestOpenAIAssistantProviderInit:
"""Test initialization fails without API key when settings return None."""
from unittest.mock import patch
# Mock OpenAISettings to return None for api_key
with patch("agent_framework.openai._assistant_provider.OpenAISettings") as mock_settings:
mock_settings.return_value.api_key = None
# Mock load_settings to return a dict with None for api_key
with patch("agent_framework.openai._assistant_provider.load_settings") as mock_load:
mock_load.return_value = {
"api_key": None,
"org_id": None,
"base_url": None,
"chat_model_id": None,
"responses_model_id": None,
}
with pytest.raises(ServiceInitializationError) as exc_info:
OpenAIAssistantProvider()
@@ -146,7 +146,7 @@ def test_init_auto_create_client(
def test_init_validation_fail() -> None:
"""Test OpenAIAssistantsClient initialization with validation failure."""
with pytest.raises(ServiceInitializationError):
# Force failure by providing invalid model ID type - this should cause validation to fail
# Force failure by providing invalid model ID type
OpenAIAssistantsClient(model_id=123, api_key="valid-key") # type: ignore