Python: [BREAKING] Redesign Python exception hierarchy (#4082)

* [BREAKING] Redesign Python exception hierarchy

Replace the flat ServiceException family with domain-scoped branches:
- AgentException (with InvalidAuth, InvalidRequest, InvalidResponse, ContentFilter)
- ChatClientException (same consistent suberrors)
- IntegrationException (same + InitializationError)
- WorkflowException (Runner, Convergence, Checkpoint, Validation, Action, Declarative)
- ContentError (AdditionItemMismatch)
- ToolException / ToolExecutionException (unchanged)
- MiddlewareException / MiddlewareTermination (unchanged)

Key changes:
- All Service* exceptions removed (ServiceException, ServiceInitializationError, etc.)
- AgentExecutionException split into AgentInvalidRequest/ResponseException
- AgentInvocationError removed, split into AgentInvalidRequest/ResponseException
- Workflow exceptions moved from _workflows/_exceptions.py into main exceptions.py
- _workflows/__init__.py emptied; main __init__.py imports directly from submodules
- Purview exceptions re-parented under IntegrationException hierarchy
- Init validation errors use built-in ValueError/TypeError instead of custom exceptions
- CODING_STANDARD.md updated with hierarchy design and rationale

Fixes microsoft/agent-framework#3410

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Clarify ToolException vs ToolExecutionException docstrings

ToolException: base class for all tool-related exceptions (preconditions,
connection/init failures).
ToolExecutionException: runtime call failures (tool call failed, reconnect
failed, MCP errors).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix remaining stale imports from agent_framework._workflows

- azurefunctions: _context.py, _app.py, _serialization.py, test_func_utils.py
  used 'from agent_framework._workflows import X' which broke after
  emptying _workflows/__init__.py; changed to direct submodule imports
- azure-ai-search: test still referenced ServiceInitializationError;
  updated to ValueError to match production code

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-02-19 18:58:14 +01:00
committed by GitHub
Unverified
parent 7f606a2e3a
commit 5ee06853a1
90 changed files with 642 additions and 718 deletions
@@ -106,62 +106,78 @@ from ._types import (
validate_tool_mode,
validate_tools,
)
from ._workflows import (
DEFAULT_MAX_ITERATIONS,
from ._workflows._agent import WorkflowAgent
from ._workflows._agent_executor import (
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
Case,
)
from ._workflows._agent_utils import resolve_agent_id
from ._workflows._checkpoint import (
CheckpointStorage,
FileCheckpointStorage,
InMemoryCheckpointStorage,
WorkflowCheckpoint,
)
from ._workflows._const import (
DEFAULT_MAX_ITERATIONS,
)
from ._workflows._edge import (
Case,
Default,
Edge,
EdgeCondition,
EdgeDuplicationError,
Executor,
FanInEdgeGroup,
FanOutEdgeGroup,
FileCheckpointStorage,
FunctionExecutor,
GraphConnectivityError,
InMemoryCheckpointStorage,
InProcRunnerContext,
Runner,
RunnerContext,
SingleEdgeGroup,
SubWorkflowRequestMessage,
SubWorkflowResponseMessage,
SwitchCaseEdgeGroup,
SwitchCaseEdgeGroupCase,
SwitchCaseEdgeGroupDefault,
TypeCompatibilityError,
ValidationTypeEnum,
Workflow,
WorkflowAgent,
WorkflowBuilder,
WorkflowCheckpoint,
WorkflowCheckpointException,
WorkflowContext,
WorkflowConvergenceException,
)
from ._workflows._edge_runner import create_edge_runner
from ._workflows._events import (
WorkflowErrorDetails,
WorkflowEvent,
WorkflowEventSource,
WorkflowEventType,
WorkflowException,
WorkflowExecutor,
WorkflowMessage,
WorkflowRunnerException,
WorkflowRunResult,
WorkflowRunState,
WorkflowValidationError,
WorkflowViz,
create_edge_runner,
executor,
)
from ._workflows._executor import (
Executor,
handler,
resolve_agent_id,
response_handler,
)
from ._workflows._function_executor import FunctionExecutor, executor
from ._workflows._request_info_mixin import response_handler
from ._workflows._runner import Runner
from ._workflows._runner_context import (
InProcRunnerContext,
RunnerContext,
WorkflowMessage,
)
from ._workflows._validation import (
EdgeDuplicationError,
GraphConnectivityError,
TypeCompatibilityError,
ValidationTypeEnum,
WorkflowValidationError,
validate_workflow_graph,
)
from .exceptions import MiddlewareException
from ._workflows._viz import WorkflowViz
from ._workflows._workflow import Workflow, WorkflowRunResult
from ._workflows._workflow_builder import WorkflowBuilder
from ._workflows._workflow_context import WorkflowContext
from ._workflows._workflow_executor import (
SubWorkflowRequestMessage,
SubWorkflowResponseMessage,
WorkflowExecutor,
)
from .exceptions import (
MiddlewareException,
WorkflowCheckpointException,
WorkflowConvergenceException,
WorkflowException,
WorkflowRunnerException,
)
__all__ = [
"AGENT_FRAMEWORK_USER_AGENT",
@@ -51,7 +51,7 @@ from ._types import (
map_chat_to_agent_update,
normalize_messages,
)
from .exceptions import AgentExecutionException
from .exceptions import AgentInvalidResponseException
from .observability import AgentTelemetryLayer
if sys.version_info >= (3, 13):
@@ -843,7 +843,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
)
if not response:
raise AgentExecutionException("Chat client did not return a response.")
raise AgentInvalidResponseException("Chat client did not return a response.")
await self._finalize_response(
response=response,
@@ -118,7 +118,7 @@ def _coerce_value(value: str, target_type: type) -> Any:
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
Raises ``ValueError`` when the override is clearly
incompatible (e.g. a ``dict`` passed where ``str`` is expected).
Callable values and ``None`` are always accepted.
"""
@@ -155,10 +155,8 @@ def _check_override_type(value: Any, field_type: type, field_name: str) -> None:
if isinstance(value, int) and float in allowed:
return
from .exceptions import ServiceInitializationError
allowed_names = ", ".join(t.__name__ for t in allowed)
raise ServiceInitializationError(
raise ValueError(
f"Invalid type for setting '{field_name}': expected {allowed_names}, got {type(value).__name__}."
)
@@ -207,7 +205,7 @@ def load_settings(
FileNotFoundError: If *env_file_path* was provided but the file does not exist.
SettingNotFoundError: If a required field could not be resolved from any
source, or if a mutually exclusive constraint is violated.
ServiceInitializationError: If an override value has an incompatible type.
ValueError: If an override value has an incompatible type.
"""
encoding = env_file_encoding or "utf-8"
@@ -1,15 +0,0 @@
# Get Started with Microsoft Agent Framework Workflows
Workflow capabilities ship with the core `agent-framework` package.
```bash
pip install agent-framework --pre
```
Optional visualization support is still available via the `viz` extra:
```bash
pip install agent-framework[viz] --pre
```
See the [project README](https://github.com/microsoft/agent-framework/tree/main/python/README.md) for more information.
@@ -1,150 +1 @@
# Copyright (c) Microsoft. All rights reserved.
"""Workflow namespace for built-in Agent Framework orchestration primitives.
This module re-exports objects from workflow implementation modules under
``agent_framework._workflows``.
Supported classes include:
- Workflow
- WorkflowBuilder
- AgentExecutor
- Runner
- WorkflowExecutor
"""
from ._agent import WorkflowAgent
from ._agent_executor import (
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
)
from ._agent_utils import resolve_agent_id
from ._checkpoint import (
CheckpointStorage,
FileCheckpointStorage,
InMemoryCheckpointStorage,
WorkflowCheckpoint,
)
from ._checkpoint_encoding import (
decode_checkpoint_value,
encode_checkpoint_value,
)
from ._const import (
DEFAULT_MAX_ITERATIONS,
)
from ._edge import (
Case,
Default,
Edge,
EdgeCondition,
FanInEdgeGroup,
FanOutEdgeGroup,
SingleEdgeGroup,
SwitchCaseEdgeGroup,
SwitchCaseEdgeGroupCase,
SwitchCaseEdgeGroupDefault,
)
from ._edge_runner import create_edge_runner
from ._events import (
WorkflowErrorDetails,
WorkflowEvent,
WorkflowEventSource,
WorkflowEventType,
WorkflowRunState,
)
from ._exceptions import (
WorkflowCheckpointException,
WorkflowConvergenceException,
WorkflowException,
WorkflowRunnerException,
)
from ._executor import (
Executor,
handler,
)
from ._function_executor import FunctionExecutor, executor
from ._request_info_mixin import response_handler
from ._runner import Runner
from ._runner_context import (
InProcRunnerContext,
RunnerContext,
WorkflowMessage,
)
from ._state import State
from ._validation import (
EdgeDuplicationError,
GraphConnectivityError,
TypeCompatibilityError,
ValidationTypeEnum,
WorkflowValidationError,
validate_workflow_graph,
)
from ._viz import WorkflowViz
from ._workflow import Workflow, WorkflowRunResult
from ._workflow_builder import WorkflowBuilder
from ._workflow_context import WorkflowContext
from ._workflow_executor import (
SubWorkflowRequestMessage,
SubWorkflowResponseMessage,
WorkflowExecutor,
)
__all__ = [
"DEFAULT_MAX_ITERATIONS",
"AgentExecutor",
"AgentExecutorRequest",
"AgentExecutorResponse",
"Case",
"CheckpointStorage",
"Default",
"Edge",
"EdgeCondition",
"EdgeDuplicationError",
"Executor",
"FanInEdgeGroup",
"FanOutEdgeGroup",
"FileCheckpointStorage",
"FunctionExecutor",
"GraphConnectivityError",
"InMemoryCheckpointStorage",
"InProcRunnerContext",
"Runner",
"RunnerContext",
"SingleEdgeGroup",
"State",
"SubWorkflowRequestMessage",
"SubWorkflowResponseMessage",
"SwitchCaseEdgeGroup",
"SwitchCaseEdgeGroupCase",
"SwitchCaseEdgeGroupDefault",
"TypeCompatibilityError",
"ValidationTypeEnum",
"Workflow",
"WorkflowAgent",
"WorkflowBuilder",
"WorkflowCheckpoint",
"WorkflowCheckpointException",
"WorkflowContext",
"WorkflowConvergenceException",
"WorkflowErrorDetails",
"WorkflowEvent",
"WorkflowEventSource",
"WorkflowEventType",
"WorkflowException",
"WorkflowExecutor",
"WorkflowMessage",
"WorkflowRunResult",
"WorkflowRunState",
"WorkflowRunnerException",
"WorkflowValidationError",
"WorkflowViz",
"create_edge_runner",
"decode_checkpoint_value",
"encode_checkpoint_value",
"executor",
"handler",
"resolve_agent_id",
"response_handler",
"validate_workflow_graph",
]
@@ -29,7 +29,7 @@ from .._types import (
UsageDetails,
add_usage_details,
)
from ..exceptions import AgentExecutionException
from ..exceptions import AgentInvalidRequestException, AgentInvalidResponseException
from ._checkpoint import CheckpointStorage
from ._events import (
WorkflowEvent,
@@ -456,7 +456,7 @@ class WorkflowAgent(BaseAgent):
# We cannot support AgentResponseUpdate in non-streaming mode. This is because the message
# sequence cannot be guaranteed when there are streaming updates in between non-streaming
# responses.
raise AgentExecutionException(
raise AgentInvalidRequestException(
"Output event with AgentResponseUpdate data cannot be emitted in non-streaming mode. "
"Please ensure executors emit AgentResponse for non-streaming workflows."
)
@@ -669,24 +669,24 @@ class WorkflowAgent(BaseAgent):
try:
parsed_args = self.RequestInfoFunctionArgs.from_json(arguments_payload)
except ValueError as exc:
raise AgentExecutionException(
raise AgentInvalidResponseException(
"FunctionApprovalResponseContent arguments must decode to a mapping."
) from exc
elif isinstance(arguments_payload, dict):
parsed_args = self.RequestInfoFunctionArgs.from_dict(arguments_payload)
else:
raise AgentExecutionException(
raise AgentInvalidResponseException(
"FunctionApprovalResponseContent arguments must be a mapping or JSON string."
)
request_id = parsed_args.request_id or content.id # type: ignore[attr-defined]
if not content.approved: # type: ignore[attr-defined]
raise AgentExecutionException(f"Request '{request_id}' was not approved by the caller.")
raise AgentInvalidResponseException(f"Request '{request_id}' was not approved by the caller.")
if request_id in self.pending_requests:
function_responses[request_id] = parsed_args.data
elif bool(self.pending_requests):
raise AgentExecutionException(
raise AgentInvalidRequestException(
"Only responses for pending requests are allowed when there are outstanding approvals."
)
elif content.type == "function_result":
@@ -695,12 +695,14 @@ class WorkflowAgent(BaseAgent):
response_data = content.result if hasattr(content, "result") else str(content) # type: ignore[attr-defined]
function_responses[request_id] = response_data
elif bool(self.pending_requests):
raise AgentExecutionException(
raise AgentInvalidRequestException(
"Only function responses for pending requests are allowed while requests are outstanding."
)
else:
if bool(self.pending_requests):
raise AgentExecutionException("Unexpected content type while awaiting request info responses.")
raise AgentInvalidResponseException(
"Unexpected content type while awaiting request info responses."
)
return function_responses
def _extract_contents(self, data: Any) -> list[Content]:
@@ -14,7 +14,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol, TypeAlias
from ._exceptions import WorkflowCheckpointException
from ..exceptions import WorkflowCheckpointException
logger = logging.getLogger(__name__)
@@ -324,12 +324,12 @@ class FileCheckpointStorage:
encoded_checkpoint = await asyncio.to_thread(_read)
from ._checkpoint_encoding import CheckpointDecodingError, decode_checkpoint_value
from ._checkpoint_encoding import decode_checkpoint_value
try:
decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint)
except CheckpointDecodingError as exc:
raise WorkflowCheckpointException(f"Failed to decode checkpoint {checkpoint_id}: {exc}") from exc
except WorkflowCheckpointException:
raise
checkpoint = WorkflowCheckpoint.from_dict(decoded_checkpoint_dict)
logger.info(f"Loaded checkpoint {checkpoint_id} from {file_path}")
return checkpoint
@@ -7,6 +7,8 @@ import logging
import pickle # nosec # noqa: S403
from typing import Any
from ..exceptions import WorkflowCheckpointException
"""Checkpoint encoding using JSON structure with pickle+base64 for arbitrary data.
This hybrid approach provides:
@@ -29,10 +31,6 @@ _TYPE_MARKER = "__type__"
_JSON_NATIVE_TYPES = (str, int, float, bool, type(None))
class CheckpointDecodingError(Exception):
"""Raised when checkpoint decoding fails due to type mismatch or corruption."""
def encode_checkpoint_value(value: Any) -> Any:
"""Encode a Python value for checkpoint storage.
@@ -68,7 +66,7 @@ def decode_checkpoint_value(value: Any) -> Any:
The original Python value.
Raises:
CheckpointDecodingError: If the unpickled object's type doesn't match
WorkflowCheckpointException: If the unpickled object's type doesn't match
the recorded type, indicating corruption, or if the base64/pickle
data is malformed.
"""
@@ -133,11 +131,11 @@ def _verify_type(obj: Any, expected_type_key: str) -> None:
expected_type_key: The recorded type key (module:qualname format).
Raises:
CheckpointDecodingError: If the types don't match.
WorkflowCheckpointException: If the types don't match.
"""
actual_type_key = _type_to_key(type(obj)) # type: ignore
if actual_type_key != expected_type_key:
raise CheckpointDecodingError(
raise WorkflowCheckpointException(
f"Type mismatch during checkpoint decoding: "
f"expected '{expected_type_key}', got '{actual_type_key}'. "
f"The checkpoint may be corrupted or tampered with."
@@ -154,14 +152,14 @@ def _base64_to_unpickle(encoded: str) -> Any:
"""Decode base64 string and unpickle.
Raises:
CheckpointDecodingError: If the base64 data is corrupted or the pickle
WorkflowCheckpointException: If the base64 data is corrupted or the pickle
format is incompatible.
"""
try:
pickled = base64.b64decode(encoded.encode("ascii"))
return pickle.loads(pickled) # nosec # noqa: S301
except Exception as exc:
raise CheckpointDecodingError(f"Failed to decode pickled checkpoint data: {exc}") from exc
raise WorkflowCheckpointException(f"Failed to decode pickled checkpoint data: {exc}") from exc
def _type_to_key(t: type) -> str:
@@ -1,27 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from ..exceptions import AgentFrameworkException
class WorkflowException(AgentFrameworkException):
"""Base exception for workflow errors."""
pass
class WorkflowRunnerException(WorkflowException):
"""Base exception for workflow runner errors."""
pass
class WorkflowConvergenceException(WorkflowRunnerException):
"""Exception raised when a workflow runner fails to converge within the maximum iterations."""
pass
class WorkflowCheckpointException(WorkflowRunnerException):
"""Exception raised for errors related to workflow checkpoints."""
pass
@@ -7,16 +7,16 @@ from collections import defaultdict
from collections.abc import AsyncGenerator, Sequence
from typing import Any
from ..exceptions import (
WorkflowCheckpointException,
WorkflowConvergenceException,
WorkflowRunnerException,
)
from ._checkpoint import CheckpointID, CheckpointStorage, WorkflowCheckpoint
from ._const import EXECUTOR_STATE_KEY
from ._edge import EdgeGroup
from ._edge_runner import EdgeRunner, create_edge_runner
from ._events import WorkflowEvent
from ._exceptions import (
WorkflowCheckpointException,
WorkflowConvergenceException,
WorkflowRunnerException,
)
from ._executor import Executor
from ._runner_context import (
RunnerContext,
@@ -7,6 +7,7 @@ from collections.abc import Sequence
from enum import Enum
from typing import Any
from ..exceptions import WorkflowException
from ._edge import Edge, EdgeGroup, FanInEdgeGroup, InternalEdgeGroup
from ._executor import Executor
from ._typing_utils import is_type_compatible
@@ -26,7 +27,7 @@ class ValidationTypeEnum(Enum):
OUTPUT_VALIDATION = "OUTPUT_VALIDATION"
class WorkflowValidationError(Exception):
class WorkflowValidationError(WorkflowException):
"""Base exception for workflow validation errors."""
def __init__(self, message: str, validation_type: ValidationTypeEnum):
@@ -9,7 +9,6 @@ from typing import Any, ClassVar, Generic
from openai.lib.azure import AsyncAzureOpenAI
from .._settings import load_settings
from ..exceptions import ServiceInitializationError
from ..openai import OpenAIAssistantsClient
from ..openai._assistants_client import OpenAIAssistantsOptions
from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider, resolve_credential_to_token_provider
@@ -147,7 +146,7 @@ class AzureOpenAIAssistantsClient(
_apply_azure_defaults(azure_openai_settings, default_api_version=self.DEFAULT_AZURE_API_VERSION)
if not azure_openai_settings["chat_deployment_name"]:
raise ServiceInitializationError(
raise ValueError(
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable."
)
@@ -160,7 +159,7 @@ class AzureOpenAIAssistantsClient(
)
if not async_client and not azure_openai_settings["api_key"] and not ad_token_provider:
raise ServiceInitializationError("Please provide either api_key, credential, or a client.")
raise ValueError("Please provide either api_key, credential, or a client.")
# Create Azure client if not provided
if not async_client:
@@ -22,7 +22,6 @@ from agent_framework import (
FunctionInvocationConfiguration,
FunctionInvocationLayer,
)
from agent_framework.exceptions import ServiceInitializationError
from agent_framework.observability import ChatTelemetryLayer
from agent_framework.openai import OpenAIChatOptions
from agent_framework.openai._chat_client import RawOpenAIChatClient
@@ -262,7 +261,7 @@ class AzureOpenAIChatClient( # type: ignore[misc]
_apply_azure_defaults(azure_openai_settings)
if not azure_openai_settings["chat_deployment_name"]:
raise ServiceInitializationError(
raise ValueError(
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable."
)
@@ -9,7 +9,7 @@ from typing import Union
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
from ..exceptions import ServiceInvalidAuthError
from ..exceptions import ChatClientInvalidAuthException
logger: logging.Logger = logging.getLogger(__name__)
@@ -54,7 +54,7 @@ def resolve_credential_to_token_provider(
return credential
if not token_endpoint:
raise ServiceInvalidAuthError(
raise ChatClientInvalidAuthException(
"A token endpoint must be provided either in settings, as an environment variable, or as an argument."
)
@@ -14,7 +14,6 @@ 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
from ..observability import ChatTelemetryLayer
from ..openai._responses_client import RawOpenAIResponsesClient
from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider
@@ -217,7 +216,7 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
azure_openai_settings["base_url"] = urljoin(str(azure_openai_settings["endpoint"]), "/openai/v1/")
if not azure_openai_settings["responses_deployment_name"]:
raise ServiceInitializationError(
raise ValueError(
"Azure OpenAI deployment name is required. Set via 'deployment_name' parameter "
"or 'AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME' environment variable."
)
@@ -255,20 +254,16 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
An AsyncAzureOpenAI client obtained from the project client.
Raises:
ServiceInitializationError: If required parameters are missing or
ValueError: If required parameters are missing or
the azure-ai-projects package is not installed.
"""
if project_client is not None:
return project_client.get_openai_client()
if not project_endpoint:
raise ServiceInitializationError(
"Azure AI project endpoint is required when project_client is not provided."
)
raise ValueError("Azure AI project endpoint is required when project_client is not provided.")
if not credential:
raise ServiceInitializationError(
"Azure credential is required when using project_endpoint without a project_client."
)
raise ValueError("Azure credential is required when using project_endpoint without a project_client.")
project_client = AIProjectClient(
endpoint=project_endpoint,
credential=credential, # type: ignore[arg-type]
@@ -13,7 +13,6 @@ from openai.lib.azure import AsyncAzureOpenAI
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 AzureCredentialTypes, AzureTokenProvider, resolve_credential_to_token_provider
@@ -175,10 +174,10 @@ class AzureOpenAIConfigMixin(OpenAIBase):
ad_token_provider = resolve_credential_to_token_provider(credential, token_endpoint)
if not api_key and not ad_token_provider:
raise ServiceInitializationError("Please provide either api_key, credential, or a client.")
raise ValueError("Please provide either api_key, credential, or a client.")
if not endpoint and not base_url:
raise ServiceInitializationError("Please provide an endpoint or a base_url")
raise ValueError("Please provide an endpoint or a base_url")
args: dict[str, Any] = {
"default_headers": merged_headers,
@@ -21,7 +21,6 @@ _IMPORTS = [
"AgentFactory",
"AgentExternalInputRequest",
"AgentExternalInputResponse",
"AgentInvocationError",
"DeclarativeLoaderError",
"DeclarativeWorkflowError",
"ExternalInputRequest",
@@ -1,6 +1,10 @@
# Copyright (c) Microsoft. All rights reserved.
"""Exception hierarchy used across Agent Framework core and connectors."""
"""Exception hierarchy used across Agent Framework core and connectors.
See python/CODING_STANDARD.md § Exception Hierarchy for design rationale
and guidance on choosing the correct exception class.
"""
import logging
from typing import Any, Literal
@@ -9,7 +13,7 @@ logger = logging.getLogger("agent_framework")
class AgentFrameworkException(Exception):
"""Base exceptions for the Agent Framework.
"""Base exception for the Agent Framework.
Automatically logs the message as debug.
"""
@@ -33,115 +37,118 @@ class AgentFrameworkException(Exception):
super().__init__(message, *args) # type: ignore
# region Agent Exceptions
class AgentException(AgentFrameworkException):
"""Base class for all agent exceptions."""
pass
class AgentExecutionException(AgentException):
"""An error occurred while executing the agent."""
class AgentInvalidAuthException(AgentException):
"""An authentication error occurred in an agent."""
pass
class AgentInitializationError(AgentException):
"""An error occurred while initializing the agent."""
class AgentInvalidRequestException(AgentException):
"""An invalid request was made to an agent."""
pass
class AgentSessionException(AgentException):
"""An error occurred while managing the agent session."""
class AgentInvalidResponseException(AgentException):
"""An invalid or unexpected response was received from an agent."""
pass
class AgentContentFilterException(AgentException):
"""A content filter was triggered by an agent."""
pass
# endregion
# region Chat Client Exceptions
class ChatClientException(AgentFrameworkException):
"""An error occurred while dealing with a chat client."""
"""Base class for all chat client exceptions."""
pass
class ChatClientInitializationError(ChatClientException):
"""An error occurred while initializing the chat client."""
class ChatClientInvalidAuthException(ChatClientException):
"""An authentication error occurred in a chat client."""
pass
# region Service Exceptions
class ServiceException(AgentFrameworkException):
"""Base class for all service exceptions."""
class ChatClientInvalidRequestException(ChatClientException):
"""An invalid request was made to a chat client."""
pass
class ServiceInitializationError(ServiceException):
"""An error occurred while initializing the service."""
class ChatClientInvalidResponseException(ChatClientException):
"""An invalid or unexpected response was received from a chat client."""
pass
class ServiceResponseException(ServiceException):
"""Base class for all service response exceptions."""
class ChatClientContentFilterException(ChatClientException):
"""A content filter was triggered by a chat client."""
pass
class ServiceContentFilterException(ServiceResponseException):
"""An error was raised by the content filter of the service."""
# endregion
# region Integration Exceptions
class IntegrationException(AgentFrameworkException):
"""Base class for all external service/dependency integration exceptions."""
pass
class ServiceInvalidAuthError(ServiceException):
"""An error occurred while authenticating the service."""
class IntegrationInitializationError(IntegrationException):
"""A wrapped dependency/service lifecycle failure occurred during setup."""
pass
class ServiceInvalidExecutionSettingsError(ServiceResponseException):
"""An error occurred while validating the execution settings of the service."""
class IntegrationInvalidAuthException(IntegrationException):
"""An authentication error occurred in an external integration."""
pass
class ServiceInvalidRequestError(ServiceResponseException):
"""An error occurred while validating the request to the service."""
class IntegrationInvalidRequestException(IntegrationException):
"""An invalid request was made to an external integration."""
pass
class ServiceInvalidResponseError(ServiceResponseException):
"""An error occurred while validating the response from the service."""
class IntegrationInvalidResponseException(IntegrationException):
"""An invalid or unexpected response was received from an external integration."""
pass
class ToolException(AgentFrameworkException):
"""An error occurred while executing a tool."""
class IntegrationContentFilterException(IntegrationException):
"""A content filter was triggered by an external integration."""
pass
class ToolExecutionException(ToolException):
"""An error occurred while executing a tool."""
# endregion
pass
class AdditionItemMismatch(AgentFrameworkException):
"""An error occurred while adding two types."""
pass
class MiddlewareException(AgentFrameworkException):
"""An error occurred during middleware execution."""
pass
# region Content Exceptions
class ContentError(AgentFrameworkException):
@@ -150,7 +157,78 @@ class ContentError(AgentFrameworkException):
pass
class AdditionItemMismatch(ContentError):
"""A type mismatch occurred while merging content items."""
pass
# endregion
# region Tool Exceptions
class ToolException(AgentFrameworkException):
"""Base class for all tool-related exceptions."""
pass
class ToolExecutionException(ToolException):
"""A tool or prompt call failed at runtime."""
pass
# endregion
# region Middleware Exceptions
class MiddlewareException(AgentFrameworkException):
"""An error occurred during middleware execution."""
pass
# endregion
# region Settings Exceptions
class SettingNotFoundError(AgentFrameworkException):
"""A required setting could not be resolved from any source."""
pass
# endregion
# region Workflow Exceptions
class WorkflowException(AgentFrameworkException):
"""Base exception for workflow errors."""
pass
class WorkflowRunnerException(WorkflowException):
"""Base exception for workflow runner errors."""
pass
class WorkflowConvergenceException(WorkflowRunnerException):
"""Exception raised when a workflow runner fails to converge within the maximum iterations."""
pass
class WorkflowCheckpointException(WorkflowRunnerException):
"""Exception raised for errors related to workflow checkpoints."""
pass
# endregion
@@ -16,7 +16,6 @@ from .._agents import Agent
from .._middleware import MiddlewareTypes
from .._sessions import BaseContextProvider
from .._tools import FunctionTool, ToolTypes, normalize_tools
from ..exceptions import ServiceInitializationError
from ._assistants_client import OpenAIAssistantsClient
from ._shared import OpenAISettings, from_assistant_tools, to_assistant_tools
@@ -120,7 +119,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
env_file_encoding: Encoding of the .env file.
Raises:
ServiceInitializationError: If no client is provided and API key is missing.
ValueError: If no client is provided and API key is missing.
Examples:
.. code-block:: python
@@ -151,7 +150,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
)
if not settings["api_key"]:
raise ServiceInitializationError(
raise ValueError(
"OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable."
)
@@ -227,7 +226,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
A Agent instance wrapping the created assistant.
Raises:
ServiceInitializationError: If assistant creation fails.
ValueError: If assistant creation fails.
Examples:
.. code-block:: python
@@ -286,7 +285,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
# Create the assistant
if not self._client:
raise ServiceInitializationError("OpenAI client is not initialized.")
raise RuntimeError("OpenAI client is not initialized.")
assistant = await self._client.beta.assistants.create(**create_params)
@@ -333,7 +332,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
A Agent instance wrapping the retrieved assistant.
Raises:
ServiceInitializationError: If the assistant cannot be retrieved.
RuntimeError: If the assistant cannot be retrieved.
ValueError: If required function tools are missing.
Examples:
@@ -352,7 +351,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]):
"""
# Fetch the assistant
if not self._client:
raise ServiceInitializationError("OpenAI client is not initialized.")
raise RuntimeError("OpenAI client is not initialized.")
assistant = await self._client.beta.assistants.retrieve(assistant_id)
@@ -47,7 +47,6 @@ from .._types import (
ResponseStream,
UsageDetails,
)
from ..exceptions import ServiceInitializationError
from ..observability import ChatTelemetryLayer
from ._shared import OpenAIConfigMixin, OpenAISettings
@@ -350,11 +349,11 @@ class OpenAIAssistantsClient( # type: ignore[misc]
)
if not async_client and not openai_settings["api_key"]:
raise ServiceInitializationError(
raise ValueError(
"OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable."
)
if not openai_settings["chat_model_id"]:
raise ServiceInitializationError(
raise ValueError(
"OpenAI model ID is required. "
"Set via 'model_id' parameter or 'OPENAI_CHAT_MODEL_ID' environment variable."
)
@@ -452,7 +451,7 @@ class OpenAIAssistantsClient( # type: ignore[misc]
# If no assistant is provided, create a temporary assistant
if self.assistant_id is None:
if not self.model_id:
raise ServiceInitializationError("Parameter 'model_id' is required for assistant creation.")
raise ValueError("Parameter 'model_id' is required for assistant creation.")
client = await self._ensure_client()
created_assistant = await client.beta.assistants.create(
@@ -41,9 +41,8 @@ from .._types import (
UsageDetails,
)
from ..exceptions import (
ServiceInitializationError,
ServiceInvalidRequestError,
ServiceResponseException,
ChatClientException,
ChatClientInvalidRequestException,
)
from ..observability import ChatTelemetryLayer
from ._exceptions import OpenAIContentFilterException
@@ -234,12 +233,12 @@ class RawOpenAIChatClient( # type: ignore[misc]
f"{type(self)} service encountered a content error: {ex}",
inner_exception=ex,
) from ex
raise ServiceResponseException(
raise ChatClientException(
f"{type(self)} service failed to complete the prompt: {ex}",
inner_exception=ex,
) from ex
except Exception as ex:
raise ServiceResponseException(
raise ChatClientException(
f"{type(self)} service failed to complete the prompt: {ex}",
inner_exception=ex,
) from ex
@@ -259,12 +258,12 @@ class RawOpenAIChatClient( # type: ignore[misc]
f"{type(self)} service encountered a content error: {ex}",
inner_exception=ex,
) from ex
raise ServiceResponseException(
raise ChatClientException(
f"{type(self)} service failed to complete the prompt: {ex}",
inner_exception=ex,
) from ex
except Exception as ex:
raise ServiceResponseException(
raise ChatClientException(
f"{type(self)} service failed to complete the prompt: {ex}",
inner_exception=ex,
) from ex
@@ -320,7 +319,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
if messages and "messages" not in run_options:
run_options["messages"] = self._prepare_messages_for_openai(messages)
if "messages" not in run_options:
raise ServiceInvalidRequestError("Messages are required for chat completions")
raise ChatClientInvalidRequestException("Messages are required for chat completions")
# Translation between options keys and Chat Completion API
for old_key, new_key in OPTION_TRANSLATIONS.items():
@@ -732,11 +731,11 @@ class OpenAIChatClient( # type: ignore[misc]
)
if not async_client and not openai_settings["api_key"]:
raise ServiceInitializationError(
raise ValueError(
"OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable."
)
if not openai_settings["chat_model_id"]:
raise ServiceInitializationError(
raise ValueError(
"OpenAI model ID is required. "
"Set via 'model_id' parameter or 'OPENAI_CHAT_MODEL_ID' environment variable."
)
@@ -8,7 +8,7 @@ from typing import Any
from openai import BadRequestError
from ..exceptions import ServiceContentFilterException
from ..exceptions import ChatClientContentFilterException
class ContentFilterResultSeverity(Enum):
@@ -54,7 +54,7 @@ class ContentFilterCodes(Enum):
@dataclass
class OpenAIContentFilterException(ServiceContentFilterException):
class OpenAIContentFilterException(ChatClientContentFilterException):
"""AI exception for an error from Azure OpenAI's content filter."""
# The parameter that caused the error.
@@ -61,9 +61,8 @@ from .._types import (
validate_tool_mode,
)
from ..exceptions import (
ServiceInitializationError,
ServiceInvalidRequestError,
ServiceResponseException,
ChatClientException,
ChatClientInvalidRequestException,
)
from ..observability import ChatTelemetryLayer
from ._exceptions import OpenAIContentFilterException
@@ -264,7 +263,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
f"{type(self)} service encountered a content error: {ex}",
inner_exception=ex,
) from ex
raise ServiceResponseException(
raise ChatClientException(
f"{type(self)} service failed to complete the prompt: {ex}",
inner_exception=ex,
) from ex
@@ -352,7 +351,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
) -> tuple[type[BaseModel] | None, dict[str, Any] | None]:
"""Normalize response_format into Responses text configuration and parse target."""
if text_config is not None and not isinstance(text_config, MutableMapping):
raise ServiceInvalidRequestError("text must be a mapping when provided.")
raise ChatClientInvalidRequestException("text must be a mapping when provided.")
text_config = cast(dict[str, Any], text_config) if isinstance(text_config, MutableMapping) else None
if response_format is None:
@@ -360,7 +359,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
if text_config and "format" in text_config:
raise ServiceInvalidRequestError("response_format cannot be combined with explicit text.format.")
raise ChatClientInvalidRequestException("response_format cannot be combined with explicit text.format.")
return response_format, text_config
if isinstance(response_format, Mapping):
@@ -368,11 +367,11 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
if text_config is None:
text_config = {}
elif "format" in text_config and text_config["format"] != format_config:
raise ServiceInvalidRequestError("Conflicting response_format definitions detected.")
raise ChatClientInvalidRequestException("Conflicting response_format definitions detected.")
text_config["format"] = format_config
return None, text_config
raise ServiceInvalidRequestError("response_format must be a Pydantic model or mapping.")
raise ChatClientInvalidRequestException("response_format must be a Pydantic model or mapping.")
def _convert_response_format(self, response_format: Mapping[str, Any]) -> dict[str, Any]:
"""Convert Chat style response_format into Responses text format config."""
@@ -383,11 +382,11 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
if format_type == "json_schema":
schema_section = response_format.get("json_schema", response_format)
if not isinstance(schema_section, Mapping):
raise ServiceInvalidRequestError("json_schema response_format must be a mapping.")
raise ChatClientInvalidRequestException("json_schema response_format must be a mapping.")
schema_section_typed = cast("Mapping[str, Any]", schema_section)
schema: Any = schema_section_typed.get("schema")
if schema is None:
raise ServiceInvalidRequestError("json_schema response_format requires a schema.")
raise ChatClientInvalidRequestException("json_schema response_format requires a schema.")
name: str = str(
schema_section_typed.get("name")
or schema_section_typed.get("title")
@@ -408,7 +407,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
if format_type in {"json_object", "text"}:
return {"type": format_type}
raise ServiceInvalidRequestError("Unsupported response_format provided for Responses client.")
raise ChatClientInvalidRequestException("Unsupported response_format provided for Responses client.")
def _get_conversation_id(
self, response: OpenAIResponse | ParsedResponse[BaseModel], store: bool | None
@@ -787,10 +786,8 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
# Continuation turn: instructions already exist in conversation context, skip prepending
request_input = self._prepare_messages_for_openai(messages)
if not request_input:
raise ServiceInvalidRequestError("Messages are required for chat completions")
raise ChatClientInvalidRequestException("Messages are required for chat completions")
conversation_id = self._get_current_conversation_id(options, **kwargs)
run_options["input"] = request_input
# model id
@@ -1876,11 +1873,11 @@ class OpenAIResponsesClient( # type: ignore[misc]
)
if not async_client and not openai_settings["api_key"]:
raise ServiceInitializationError(
raise ValueError(
"OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable."
)
if not openai_settings["responses_model_id"]:
raise ServiceInitializationError(
raise ValueError(
"OpenAI model ID is required. "
"Set via 'model_id' parameter or 'OPENAI_RESPONSES_MODEL_ID' environment variable."
)
@@ -26,7 +26,6 @@ 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
logger: logging.Logger = logging.getLogger("agent_framework.openai")
@@ -56,20 +55,20 @@ def _check_openai_version_for_callable_api_key() -> None:
"""Check if OpenAI version supports callable API keys.
Callable API keys require OpenAI >= 1.106.0.
If the version is too old, raise a ServiceInitializationError with helpful message.
If the version is too old, raise a ValueError with helpful message.
"""
try:
current_version = parse(openai.__version__)
min_required_version = parse("1.106.0")
if current_version < min_required_version:
raise ServiceInitializationError(
raise ValueError(
f"Callable API keys require OpenAI SDK >= 1.106.0, but you have {openai.__version__}. "
f"Please upgrade with 'pip install openai>=1.106.0' or provide a string API key instead. "
f"Note: If you're using mem0ai, you may need to upgrade to mem0ai>=1.0.0 "
f"to allow newer OpenAI versions."
)
except ServiceInitializationError:
except ValueError:
raise # Re-raise our own exception
except Exception as e:
logger.warning(f"Could not check OpenAI version for callable API key support: {e}")
@@ -172,7 +171,7 @@ class OpenAIBase(SerializationMixin):
"""Ensure OpenAI client is initialized."""
await self._initialize_client()
if self.client is None:
raise ServiceInitializationError("OpenAI client is not initialized")
raise RuntimeError("OpenAI client is not initialized")
return self.client
@@ -247,7 +246,7 @@ class OpenAIConfigMixin(OpenAIBase):
if not client:
if not api_key:
raise ServiceInitializationError("Please provide an api_key")
raise ValueError("Please provide an api_key")
args: dict[str, Any] = {"api_key": api_key_value, "default_headers": merged_headers}
if org_id:
args["organization"] = org_id
@@ -21,7 +21,6 @@ from agent_framework import (
)
from agent_framework._settings import SecretString
from agent_framework.azure import AzureOpenAIAssistantsClient
from agent_framework.exceptions import ServiceInitializationError
skip_if_azure_integration_tests_disabled = pytest.mark.skipif(
os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true"
@@ -120,7 +119,7 @@ def test_azure_assistants_client_init_auto_create_client(
def test_azure_assistants_client_init_validation_fail() -> None:
"""Test AzureOpenAIAssistantsClient initialization with validation failure."""
with pytest.raises(ServiceInitializationError):
with pytest.raises(ValueError):
# Force failure by providing invalid deployment name type - this should cause validation to fail
AzureOpenAIAssistantsClient(deployment_name=123, api_key="valid-key") # type: ignore
@@ -128,7 +127,7 @@ def test_azure_assistants_client_init_validation_fail() -> None:
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]], indirect=True)
def test_azure_assistants_client_init_missing_deployment_name(azure_openai_unit_test_env: dict[str, str]) -> None:
"""Test AzureOpenAIAssistantsClient initialization with missing deployment name."""
with pytest.raises(ServiceInitializationError):
with pytest.raises(ValueError):
AzureOpenAIAssistantsClient(api_key=azure_openai_unit_test_env.get("AZURE_OPENAI_API_KEY", "test-key"))
@@ -607,7 +606,7 @@ def test_azure_assistants_client_no_authentication_error() -> None:
}
# Test missing authentication raises error
with pytest.raises(ServiceInitializationError, match="api_key, credential, or a client"):
with pytest.raises(ValueError, match="api_key, credential, or a client"):
AzureOpenAIAssistantsClient(
deployment_name="test-deployment",
endpoint="https://test-endpoint.openai.azure.com",
@@ -28,7 +28,7 @@ from agent_framework import (
)
from agent_framework._telemetry import USER_AGENT_KEY
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException
from agent_framework.exceptions import ChatClientException
from agent_framework.openai import (
ContentFilterResultSeverity,
OpenAIContentFilterException,
@@ -93,13 +93,13 @@ def test_init_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None:
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]], indirect=True)
def test_init_with_empty_deployment_name(azure_openai_unit_test_env: dict[str, str]) -> None:
with pytest.raises(ServiceInitializationError):
with pytest.raises(ValueError):
AzureOpenAIChatClient()
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL"]], indirect=True)
def test_init_with_empty_endpoint_and_base_url(azure_openai_unit_test_env: dict[str, str]) -> None:
with pytest.raises(ServiceInitializationError):
with pytest.raises(ValueError):
AzureOpenAIChatClient()
@@ -554,7 +554,7 @@ async def test_bad_request_non_content_filter(
azure_chat_client = AzureOpenAIChatClient()
with pytest.raises(ServiceResponseException, match="service failed to complete the prompt"):
with pytest.raises(ChatClientException, match="service failed to complete the prompt"):
await azure_chat_client.get_response(
messages=chat_history,
)
@@ -21,7 +21,6 @@ from agent_framework import (
tool,
)
from agent_framework.azure import AzureOpenAIResponsesClient
from agent_framework.exceptions import ServiceInitializationError
skip_if_azure_integration_tests_disabled = pytest.mark.skipif(
os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true"
@@ -81,7 +80,7 @@ def test_init(azure_openai_unit_test_env: dict[str, str]) -> None:
def test_init_validation_fail() -> None:
# Test successful initialization
with pytest.raises(ServiceInitializationError):
with pytest.raises(ValueError):
AzureOpenAIResponsesClient(api_key="34523", deployment_name={"test": "dict"}) # type: ignore
@@ -113,7 +112,7 @@ def test_init_with_default_header(azure_openai_unit_test_env: dict[str, str]) ->
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]], indirect=True)
def test_init_with_empty_model_id(azure_openai_unit_test_env: dict[str, str]) -> None:
with pytest.raises(ServiceInitializationError):
with pytest.raises(ValueError):
AzureOpenAIResponsesClient()
@@ -212,7 +211,7 @@ def test_create_client_from_project_with_endpoint() -> None:
def test_create_client_from_project_missing_endpoint() -> None:
"""Test _create_client_from_project raises error when endpoint is missing."""
with pytest.raises(ServiceInitializationError, match="project endpoint is required"):
with pytest.raises(ValueError, match="project endpoint is required"):
AzureOpenAIResponsesClient._create_client_from_project(
project_client=None,
project_endpoint=None,
@@ -222,7 +221,7 @@ def test_create_client_from_project_missing_endpoint() -> None:
def test_create_client_from_project_missing_credential() -> None:
"""Test _create_client_from_project raises error when credential is missing."""
with pytest.raises(ServiceInitializationError, match="credential is required"):
with pytest.raises(ValueError, match="credential is required"):
AzureOpenAIResponsesClient._create_client_from_project(
project_client=None,
project_endpoint="https://test-project.services.ai.azure.com",
@@ -9,7 +9,7 @@ from azure.core.credentials_async import AsyncTokenCredential
from agent_framework.azure._entra_id_authentication import (
resolve_credential_to_token_provider,
)
from agent_framework.exceptions import ServiceInvalidAuthError
from agent_framework.exceptions import ChatClientInvalidAuthException
TOKEN_ENDPOINT = "https://cognitiveservices.azure.com/.default"
@@ -51,11 +51,11 @@ def test_resolve_callable_provider_passthrough() -> None:
def test_resolve_missing_endpoint_raises() -> None:
"""Test that missing token endpoint raises ServiceInvalidAuthError."""
"""Test that missing token endpoint raises ChatClientInvalidAuthException."""
mock_credential = MagicMock(spec=TokenCredential)
with pytest.raises(ServiceInvalidAuthError, match="A token endpoint must be provided"):
with pytest.raises(ChatClientInvalidAuthException, match="A token endpoint must be provided"):
resolve_credential_to_token_provider(mock_credential, "")
with pytest.raises(ServiceInvalidAuthError, match="A token endpoint must be provided"):
with pytest.raises(ChatClientInvalidAuthException, match="A token endpoint must be provided"):
resolve_credential_to_token_provider(mock_credential, None) # type: ignore[arg-type]
@@ -245,9 +245,8 @@ 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'"):
with pytest.raises(ValueError, match="Invalid type for setting 'api_key'"):
load_settings(SimpleSettings, env_prefix="TEST_", api_key={"bad": "type"})
def test_valid_types_accepted(self) -> None:
@@ -9,7 +9,6 @@ from openai.types.beta.assistant import Assistant
from pydantic import BaseModel, Field
from agent_framework import Agent, normalize_tools, tool
from agent_framework.exceptions import ServiceInitializationError
from agent_framework.openai import OpenAIAssistantProvider, OpenAIAssistantsClient
from agent_framework.openai._shared import from_assistant_tools, to_assistant_tools
@@ -99,7 +98,6 @@ class WeatherResponse(BaseModel):
# endregion
# region Initialization Tests
@@ -141,7 +139,7 @@ class TestOpenAIAssistantProviderInit:
"responses_model_id": None,
}
with pytest.raises(ServiceInitializationError) as exc_info:
with pytest.raises(ValueError) as exc_info:
OpenAIAssistantProvider()
assert "API key is required" in str(exc_info.value)
@@ -191,7 +189,6 @@ class TestOpenAIAssistantProviderContextManager:
# endregion
# region create_agent Tests
@@ -366,7 +363,6 @@ class TestOpenAIAssistantProviderCreateAgent:
# endregion
# region get_agent Tests
@@ -454,7 +450,6 @@ class TestOpenAIAssistantProviderGetAgent:
# endregion
# region as_agent Tests
@@ -540,7 +535,6 @@ class TestOpenAIAssistantProviderAsAgent:
# endregion
# region Tool Conversion Tests
@@ -643,7 +637,6 @@ class TestToolConversion:
# endregion
# region Tool Validation Tests
@@ -702,7 +695,6 @@ class TestToolValidation:
# endregion
# region Tool Merging Tests
@@ -760,10 +752,8 @@ class TestToolMerging:
# endregion
# region Integration Tests
skip_if_openai_integration_tests_disabled = pytest.mark.skipif(
os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true"
or os.getenv("OPENAI_API_KEY", "") in ("", "test-dummy-key"),
@@ -22,7 +22,6 @@ from agent_framework import (
SupportsChatGetResponse,
tool,
)
from agent_framework.exceptions import ServiceInitializationError
from agent_framework.openai import OpenAIAssistantsClient
skip_if_openai_integration_tests_disabled = pytest.mark.skipif(
@@ -145,7 +144,7 @@ def test_init_auto_create_client(
def test_init_validation_fail() -> None:
"""Test OpenAIAssistantsClient initialization with validation failure."""
with pytest.raises(ServiceInitializationError):
with pytest.raises(ValueError):
# Force failure by providing invalid model ID type
OpenAIAssistantsClient(model_id=123, api_key="valid-key") # type: ignore
@@ -153,14 +152,14 @@ def test_init_validation_fail() -> None:
@pytest.mark.parametrize("exclude_list", [["OPENAI_CHAT_MODEL_ID"]], indirect=True)
def test_init_missing_model_id(openai_unit_test_env: dict[str, str]) -> None:
"""Test OpenAIAssistantsClient initialization with missing model ID."""
with pytest.raises(ServiceInitializationError):
with pytest.raises(ValueError):
OpenAIAssistantsClient(api_key=openai_unit_test_env.get("OPENAI_API_KEY", "test-key"))
@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True)
def test_init_missing_api_key(openai_unit_test_env: dict[str, str]) -> None:
"""Test OpenAIAssistantsClient initialization with missing API key."""
with pytest.raises(ServiceInitializationError):
with pytest.raises(ValueError):
OpenAIAssistantsClient(model_id="gpt-4")
@@ -19,7 +19,7 @@ from agent_framework import (
SupportsChatGetResponse,
tool,
)
from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException
from agent_framework.exceptions import ChatClientException
from agent_framework.openai import OpenAIChatClient
from agent_framework.openai._exceptions import OpenAIContentFilterException
@@ -42,7 +42,7 @@ def test_init(openai_unit_test_env: dict[str, str]) -> None:
def test_init_validation_fail() -> None:
# Test successful initialization
with pytest.raises(ServiceInitializationError):
with pytest.raises(ValueError):
OpenAIChatClient(api_key="34523", model_id={"test": "dict"}) # type: ignore
@@ -96,7 +96,7 @@ def test_init_base_url_from_settings_env() -> None:
@pytest.mark.parametrize("exclude_list", [["OPENAI_CHAT_MODEL_ID"]], indirect=True)
def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None:
with pytest.raises(ServiceInitializationError):
with pytest.raises(ValueError):
OpenAIChatClient()
@@ -104,7 +104,7 @@ def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None:
def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None:
model_id = "test_model_id"
with pytest.raises(ServiceInitializationError):
with pytest.raises(ValueError):
OpenAIChatClient(
model_id=model_id,
)
@@ -235,7 +235,7 @@ async def test_exception_message_includes_original_error_details() -> None:
with (
patch.object(client.client.chat.completions, "create", side_effect=mock_error),
pytest.raises(ServiceResponseException) as exc_info,
pytest.raises(ChatClientException) as exc_info,
):
await client._inner_get_response(messages=messages, options={}) # type: ignore
@@ -779,11 +779,11 @@ def test_prepare_options_without_model_id(openai_unit_test_env: dict[str, str])
def test_prepare_options_without_messages(openai_unit_test_env: dict[str, str]) -> None:
"""Test that prepare_options raises error when messages are missing."""
from agent_framework.exceptions import ServiceInvalidRequestError
from agent_framework.exceptions import ChatClientInvalidRequestException
client = OpenAIChatClient()
with pytest.raises(ServiceInvalidRequestError, match="Messages are required"):
with pytest.raises(ChatClientInvalidRequestException, match="Messages are required"):
client._prepare_options([], {})
@@ -932,7 +932,7 @@ async def test_streaming_exception_handling(openai_unit_test_env: dict[str, str]
with (
patch.object(client.client.chat.completions, "create", side_effect=mock_error),
pytest.raises(ServiceResponseException),
pytest.raises(ChatClientException),
):
async for _ in client._inner_get_response(messages=messages, stream=True, options={}): # type: ignore
pass
@@ -15,9 +15,7 @@ from openai.types.chat.chat_completion_message import ChatCompletionMessage
from pydantic import BaseModel
from agent_framework import ChatResponseUpdate, Message
from agent_framework.exceptions import (
ServiceResponseException,
)
from agent_framework.exceptions import ChatClientException
from agent_framework.openai import OpenAIChatClient
@@ -182,7 +180,7 @@ async def test_cmc_general_exception(
chat_history.append(Message(role="user", text="hello world"))
openai_chat_completion = OpenAIChatClient()
with pytest.raises(ServiceResponseException):
with pytest.raises(ChatClientException):
await openai_chat_completion.get_response(
messages=chat_history,
)
@@ -35,11 +35,7 @@ from agent_framework import (
SupportsChatGetResponse,
tool,
)
from agent_framework.exceptions import (
ServiceInitializationError,
ServiceInvalidRequestError,
ServiceResponseException,
)
from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException
from agent_framework.openai import OpenAIResponsesClient
from agent_framework.openai._exceptions import OpenAIContentFilterException
@@ -106,7 +102,7 @@ def test_init(openai_unit_test_env: dict[str, str]) -> None:
def test_init_validation_fail() -> None:
# Test successful initialization
with pytest.raises(ServiceInitializationError):
with pytest.raises(ValueError):
OpenAIResponsesClient(api_key="34523", model_id={"test": "dict"}) # type: ignore
@@ -138,7 +134,7 @@ def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None:
@pytest.mark.parametrize("exclude_list", [["OPENAI_RESPONSES_MODEL_ID"]], indirect=True)
def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None:
with pytest.raises(ServiceInitializationError):
with pytest.raises(ValueError):
OpenAIResponsesClient()
@@ -146,7 +142,7 @@ def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None:
def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None:
model_id = "test_model_id"
with pytest.raises(ServiceInitializationError):
with pytest.raises(ValueError):
OpenAIResponsesClient(
model_id=model_id,
)
@@ -192,8 +188,8 @@ async def test_get_response_with_invalid_input() -> None:
client = OpenAIResponsesClient(model_id="invalid-model", api_key="test-key")
# Test with empty messages which should trigger ServiceInvalidRequestError
with pytest.raises(ServiceInvalidRequestError, match="Messages are required"):
# Test with empty messages which should trigger ChatClientInvalidRequestException
with pytest.raises(ChatClientInvalidRequestException, match="Messages are required"):
await client.get_response(messages=[])
@@ -201,7 +197,7 @@ async def test_get_response_with_all_parameters() -> None:
"""Test get_response with all possible parameters to cover parameter handling logic."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test with comprehensive parameter set - should fail due to invalid API key
with pytest.raises(ServiceResponseException):
with pytest.raises(ChatClientException):
await client.get_response(
messages=[Message(role="user", text="Test message")],
options={
@@ -244,7 +240,7 @@ async def test_web_search_tool_with_location() -> None:
)
# Should raise an authentication error due to invalid API key
with pytest.raises(ServiceResponseException):
with pytest.raises(ChatClientException):
await client.get_response(
messages=[Message(role="user", text="What's the weather?")],
options={"tools": [web_search_tool], "tool_choice": "auto"},
@@ -258,7 +254,7 @@ async def test_code_interpreter_tool_variations() -> None:
# Test code interpreter using static method
code_tool = OpenAIResponsesClient.get_code_interpreter_tool()
with pytest.raises(ServiceResponseException):
with pytest.raises(ChatClientException):
await client.get_response(
messages=[Message("user", ["Run some code"])],
options={"tools": [code_tool]},
@@ -267,7 +263,7 @@ async def test_code_interpreter_tool_variations() -> None:
# Test code interpreter with files using static method
code_tool_with_files = OpenAIResponsesClient.get_code_interpreter_tool(file_ids=["file1", "file2"])
with pytest.raises(ServiceResponseException):
with pytest.raises(ChatClientException):
await client.get_response(
messages=[Message(role="user", text="Process these files")],
options={"tools": [code_tool_with_files]},
@@ -303,7 +299,7 @@ async def test_hosted_file_search_tool_validation() -> None:
file_search_tool = OpenAIResponsesClient.get_file_search_tool(vector_store_ids=["vs_123"])
# Test using file search tool - may raise various exceptions depending on API response
with pytest.raises((ValueError, ServiceInvalidRequestError, ServiceResponseException)):
with pytest.raises((ValueError, ChatClientInvalidRequestException, ChatClientException)):
await client.get_response(
messages=[Message("user", ["Test"])],
options={"tools": [file_search_tool]},
@@ -331,7 +327,7 @@ async def test_chat_message_parsing_with_function_calls() -> None:
]
# This should exercise the message parsing logic - will fail due to invalid API key
with pytest.raises(ServiceResponseException):
with pytest.raises(ChatClientException):
await client.get_response(messages=messages)
@@ -401,7 +397,7 @@ async def test_bad_request_error_non_content_filter() -> None:
mock_error.code = "invalid_request"
with patch.object(client.client.responses, "parse", side_effect=mock_error):
with pytest.raises(ServiceResponseException) as exc_info:
with pytest.raises(ChatClientException) as exc_info:
await client.get_response(
messages=[Message(role="user", text="Test message")],
options={"response_format": OutputStruct},
@@ -996,7 +992,7 @@ def test_response_format_with_conflicting_definitions() -> None:
response_format = {"type": "json_schema", "format": {"type": "json_schema", "name": "Test", "schema": {}}}
text_config = {"format": {"type": "json_object"}}
with pytest.raises(ServiceInvalidRequestError, match="Conflicting response_format definitions"):
with pytest.raises(ChatClientInvalidRequestException, match="Conflicting response_format definitions"):
client._prepare_response_and_text_format(response_format=response_format, text_config=text_config)
@@ -1092,7 +1088,7 @@ def test_response_format_json_schema_missing_schema() -> None:
response_format = {"type": "json_schema", "json_schema": {"name": "NoSchema"}}
with pytest.raises(ServiceInvalidRequestError, match="json_schema response_format requires a schema"):
with pytest.raises(ChatClientInvalidRequestException, match="json_schema response_format requires a schema"):
client._prepare_response_and_text_format(response_format=response_format, text_config=None)
@@ -1102,7 +1098,7 @@ def test_response_format_unsupported_type() -> None:
response_format = {"type": "unsupported_format"}
with pytest.raises(ServiceInvalidRequestError, match="Unsupported response_format"):
with pytest.raises(ChatClientInvalidRequestException, match="Unsupported response_format"):
client._prepare_response_and_text_format(response_format=response_format, text_config=None)
@@ -1112,7 +1108,7 @@ def test_response_format_invalid_type() -> None:
response_format = "invalid" # Not a Pydantic model or mapping
with pytest.raises(ServiceInvalidRequestError, match="response_format must be a Pydantic model or mapping"):
with pytest.raises(ChatClientInvalidRequestException, match="response_format must be a Pydantic model or mapping"):
client._prepare_response_and_text_format(response_format=response_format, text_config=None) # type: ignore
@@ -1689,7 +1685,7 @@ def test_streaming_annotation_added_with_unknown_type() -> None:
async def test_service_response_exception_includes_original_error_details() -> None:
"""Test that ServiceResponseException messages include original error details in the new format."""
"""Test that ChatClientException messages include original error details in the new format."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
messages = [Message(role="user", text="test message")]
@@ -1704,7 +1700,7 @@ async def test_service_response_exception_includes_original_error_details() -> N
with (
patch.object(client.client.responses, "parse", side_effect=mock_error),
pytest.raises(ServiceResponseException) as exc_info,
pytest.raises(ChatClientException) as exc_info,
):
await client.get_response(messages=messages, options={"response_format": OutputStruct})
@@ -1719,7 +1715,7 @@ async def test_get_response_streaming_with_response_format() -> None:
messages = [Message(role="user", text="Test streaming with format")]
# It will fail due to invalid API key, but exercises the code path
with pytest.raises(ServiceResponseException):
with pytest.raises(ChatClientException):
async def run_streaming():
async for _ in client.get_response(
@@ -6,9 +6,9 @@ from typing import Any, cast
import pytest
from agent_framework import WorkflowCheckpointException
from agent_framework._workflows._checkpoint_encoding import (
_TYPE_MARKER, # type: ignore
CheckpointDecodingError,
decode_checkpoint_value,
encode_checkpoint_value,
)
@@ -178,13 +178,13 @@ def test_decode_plain_list() -> None:
def test_decode_raises_on_type_mismatch() -> None:
"""Test that decoding raises CheckpointDecodingError when type doesn't match."""
"""Test that decoding raises WorkflowCheckpointException when type doesn't match."""
# Encode a SampleRequest but tamper with the type marker
encoded = encode_checkpoint_value(SampleRequest(request_id="r1", prompt="p1"))
assert isinstance(encoded, dict)
encoded[_TYPE_MARKER] = "nonexistent.module:FakeClass"
with pytest.raises(CheckpointDecodingError, match="Type mismatch"):
with pytest.raises(WorkflowCheckpointException, match="Type mismatch"):
decode_checkpoint_value(encoded)