mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
b488158abe
commit
8457533c69
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user