From 659a0e2deafcc87d626fb40bb1e4d8edcf239bd1 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:45:39 -0700 Subject: [PATCH 1/4] fix(python): preserve provider error responses Use structured provider body codes and messages instead of inferring error titles from HTTP status when rendering response.failed events. --- .../core/agent_framework/exceptions.py | 47 ++++++++- .../core/tests/core/test_exceptions.py | 97 ++++++++++++++++++- python/packages/foundry_hosting/README.md | 7 ++ .../_responses.py | 25 +++-- .../foundry_hosting/tests/test_responses.py | 47 +++++++++ python/packages/hosting-responses/README.md | 8 ++ .../_parsing.py | 8 +- .../packages/hosting-responses/pyproject.toml | 2 +- .../tests/hosting_responses/test_parsing.py | 41 ++++++++ 9 files changed, 270 insertions(+), 12 deletions(-) diff --git a/python/packages/core/agent_framework/exceptions.py b/python/packages/core/agent_framework/exceptions.py index a908b5bce5d..4f0dcdc67af 100644 --- a/python/packages/core/agent_framework/exceptions.py +++ b/python/packages/core/agent_framework/exceptions.py @@ -7,7 +7,8 @@ """ import logging -from typing import Any, Literal +from collections.abc import Mapping +from typing import Any, Literal, cast logger = logging.getLogger("agent_framework") @@ -38,6 +39,50 @@ def __init__( super().__init__(message, *args) +def _get_response_error_details( # pyright: ignore[reportUnusedFunction] + exception: BaseException, +) -> tuple[str, str | None]: + """Preserve the provider's structured error code and message when available.""" + errors = [exception] + visited: set[int] = set() + while errors: + current = errors.pop() + if id(current) in visited: + continue + visited.add(id(current)) + status = getattr(current, "status_code", None) + if isinstance(status, int) and not isinstance(status, bool) and 400 <= status <= 599: + body = getattr(current, "body", None) + error_body: object = body + if isinstance(body, Mapping): + body_mapping = cast(Mapping[object, object], body) + nested_error = body_mapping.get("error") + if isinstance(nested_error, Mapping): + error_body = cast(Mapping[object, object], nested_error) + + code: object = None + message: object = None + if isinstance(error_body, Mapping): + error_mapping = cast(Mapping[object, object], error_body) + code = error_mapping.get("code") or error_mapping.get("type") or error_mapping.get("title") + message = error_mapping.get("message") or error_mapping.get("detail") + elif isinstance(error_body, str): + message = error_body + + if not isinstance(code, str) or not code: + code = getattr(current, "code", None) + if not isinstance(message, str) or not message: + message = getattr(current, "message", None) + if not isinstance(message, str) or not message: + message = str(current) or type(current).__name__ + return (code if isinstance(code, str) and code else "server_error"), message + if isinstance(current, AgentFrameworkException): + errors.extend(arg for arg in current.args if isinstance(arg, BaseException)) + if current.__cause__ is not None: + errors.append(current.__cause__) + return "server_error", None + + # region Agent Exceptions diff --git a/python/packages/core/tests/core/test_exceptions.py b/python/packages/core/tests/core/test_exceptions.py index 1605268cc56..28fd39a0366 100644 --- a/python/packages/core/tests/core/test_exceptions.py +++ b/python/packages/core/tests/core/test_exceptions.py @@ -2,8 +2,14 @@ """Tests for AgentFrameworkException inner_exception handling.""" +import pytest + from agent_framework import AgentFrameworkException, ResponseInvalidatedException -from agent_framework.exceptions import ChatClientException, ChatClientInvalidResponseException +from agent_framework.exceptions import ( + ChatClientException, + ChatClientInvalidResponseException, + _get_response_error_details, +) def test_exception_with_inner_exception(): @@ -42,3 +48,92 @@ def test_response_invalidated_exception_is_public_and_picklable() -> None: assert restored.args[0] == "partial response output was invalidated" assert isinstance(restored.args[1], RuntimeError) assert str(restored.args[1]) == "provider stream failed" + + +@pytest.mark.parametrize( + "status, code", + [ + (400, "invalid_request_error"), + (403, "permission_denied"), + (429, "token_rate_limit_exceeded"), + (503, "backend_overloaded"), + ], +) +def test_response_error_details_follow_framework_inner_exception(status: int, code: str) -> None: + class ProviderError(Exception): + status_code = status + + def __init__(self) -> None: + super().__init__("rendered exception") + self.body = {"error": {"code": code, "message": "provider details"}} + + error = ChatClientException("wrapper", inner_exception=ProviderError()) + actual_code, message = _get_response_error_details(error) + assert actual_code == code + assert message == "provider details" + + +def test_response_error_details_support_problem_details_title_and_detail() -> None: + class ProviderError(Exception): + status_code = 429 + body = {"title": "usage_limit_reached", "detail": "Monthly usage limit reached."} + + assert _get_response_error_details(ProviderError()) == ( + "usage_limit_reached", + "Monthly usage limit reached.", + ) + + +def test_response_error_details_fall_back_to_exception_code_and_message() -> None: + class ProviderError(Exception): + status_code = 429 + body: dict[str, object] = {} + code = "token_quota_exceeded" + message = "Token quota exceeded." + + assert _get_response_error_details(ProviderError()) == ( + "token_quota_exceeded", + "Token quota exceeded.", + ) + + +def test_response_error_details_handle_cycles_and_ignore_implicit_context() -> None: + class RateLimitError(Exception): + status_code = 429 + + outer = RuntimeError("outer") + inner = RuntimeError("inner") + outer.__cause__ = inner + inner.__cause__ = outer + inner.__context__ = RateLimitError("unrelated") + assert _get_response_error_details(outer) == ("server_error", None) + + +def test_response_error_details_respect_non_framework_wrapper_message() -> None: + class StorageConflictError(Exception): + status_code = 409 + + error = RuntimeError("Another request advanced this agent session; reload before writing.") + error.__cause__ = StorageConflictError("State store item already exists.") + assert _get_response_error_details(error) == ("server_error", None) + + +def test_response_error_details_preserve_outer_http_failure() -> None: + class ServerError(Exception): + status_code = 500 + body = {"error": {"code": "backend_overloaded", "message": "Backend is overloaded."}} + + class RateLimitError(Exception): + status_code = 429 + + error = ServerError("server failure") + error.__cause__ = RateLimitError("earlier failure") + assert _get_response_error_details(error) == ("backend_overloaded", "Backend is overloaded.") + + +@pytest.mark.parametrize("status", [True, "429", None, 200]) +def test_response_error_details_do_not_infer_unsupported_classifications(status: object) -> None: + class ProviderError(Exception): + status_code = status + + assert _get_response_error_details(ProviderError("Error code: 429")) == ("server_error", None) diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index 3a352eba761..294479b8629 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -2,6 +2,13 @@ This package provides the integration of Agent Framework agents and workflows with the Foundry Agent Server, which can be hosted on Foundry infrastructure. +Responses failures preserve the structured error title and message supplied by +the provider response body through Agent Framework exception wrappers. The +title is selected from `code`, `type`, or `title`, and the message from +`message` or `detail`, with structured exception attributes used as fallbacks. +No error title is inferred from the HTTP status. Providers and application +backends own the content and disclosure policy of the errors they return. + ## Agent instances and factories `ResponsesHostServer` and `InvocationsHostServer` accept an agent instance or a zero-argument callable through the diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index 09692723734..ca9e4cf118e 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -52,7 +52,10 @@ ) from agent_framework._mcp import _MCP_TOOL_RESULT_HOST_PAYLOAD_KEY # pyright: ignore[reportPrivateUsage] from agent_framework._telemetry import mark_feature_used -from agent_framework.exceptions import AgentFrameworkException +from agent_framework.exceptions import ( + AgentFrameworkException, + _get_response_error_details, # pyright: ignore[reportPrivateUsage] +) from agent_framework.observability import OBSERVABILITY_SETTINGS from azure.ai.agentserver.core import get_request_context from azure.ai.agentserver.responses import ( @@ -2349,11 +2352,12 @@ def _emit_failure( """Log ``ex`` and yield a terminal ``response.failed`` event. Drains any in-progress streaming output item first so the resulting - SSE stream stays well-formed, then emits ``response.failed`` carrying a - stable public message unless the caller explicitly supplies a safe - validation message. Any error raised while draining the tracker is - logged and otherwise ignored so that the original failure remains the - terminal failure. + SSE stream stays well-formed, then emits ``response.failed`` with a + supported HTTP classification and the originating provider message when + one is available. Vetted validation messages remain public, while + unexpected failures retain the stable fallback. Any tracker cleanup + error is logged and otherwise ignored so the original failure remains + terminal. """ _log_response_failure(phase, response_id, ex) if tracker is not None: @@ -2363,12 +2367,17 @@ def _emit_failure( _log_response_failure("failed tracker cleanup", response_id, close_error) tracker.discard_pending_container_file_citations() if public_message is not None: + code = "server_error" message = public_message elif isinstance(ex, _PublicResponseFailure): + code = "server_error" message = ex.public_message else: - message = _PUBLIC_STREAM_FAILURE_MESSAGE - yield response_event_stream.emit_failed(message=message, usage=tracker.usage if tracker is not None else None) + code, provider_message = _get_response_error_details(ex) + message = provider_message if provider_message is not None else _PUBLIC_STREAM_FAILURE_MESSAGE + yield response_event_stream.emit_failed( + code=code, message=message, usage=tracker.usage if tracker is not None else None + ) # endregion ResponsesHostServer diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index dc9b643bd33..3252f0167e0 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -60,6 +60,7 @@ ) from agent_framework._mcp import _MCP_TOOL_RESULT_HOST_PAYLOAD_KEY # pyright: ignore[reportPrivateUsage] from agent_framework.ag_ui import AgentFrameworkAgent, InMemoryAGUIThreadSnapshotStore +from agent_framework.exceptions import ChatClientException from agent_framework.observability import OBSERVABILITY_SETTINGS from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions, OpenAIContinuationToken from azure.ai.agentserver.core import FoundryAgentRequestContext, get_request_context @@ -119,6 +120,52 @@ _SESSION_STORAGE_ERROR_DETAIL = "session-storage-secret at C:\\sensitive\\session_store.py" +@pytest.mark.parametrize( + "status, code", + [ + (400, "invalid_request_error"), + (403, "permission_denied"), + (429, "token_rate_limit_exceeded"), + (503, "backend_overloaded"), + ], +) +@pytest.mark.parametrize("wrapper", ["direct", "cause", "inner_exception"]) +def test_failure_event_classifies_provider_http_failures(status: int, code: str, wrapper: str) -> None: + class ProviderError(Exception): + status_code = status + + def __init__(self) -> None: + super().__init__("rendered exception") + self.body = {"error": {"code": code, "message": _PRIVATE_ERROR_DETAIL}} + + error: Exception = ProviderError() + if wrapper == "cause": + outer = ChatClientException("provider wrapper") + outer.__cause__ = error + error = outer + elif wrapper == "inner_exception": + error = ChatClientException("provider wrapper", inner_exception=error) + + stream = ResponseEventStream(response_id="resp_http_failure") + stream.emit_created() + stream.emit_in_progress() + events = list( + ResponsesHostServer._emit_failure( + "resp_http_failure", + "provider HTTP failure", + stream, + None, + error, + ) + ) + terminal = events[-1] + assert terminal["type"] == "response.failed" + failure = terminal["response"]["error"] + assert failure is not None + assert failure["code"] == code + assert failure["message"] == _PRIVATE_ERROR_DETAIL + + @pytest.fixture(scope="session", autouse=True) def _isolate_agentserver_state_root(tmp_path_factory: pytest.TempPathFactory) -> Generator[None]: """Keep the real local state store, but prevent xdist workers sharing its files.""" diff --git a/python/packages/hosting-responses/README.md b/python/packages/hosting-responses/README.md index 68d6d4b88f7..620c5c8d395 100644 --- a/python/packages/hosting-responses/README.md +++ b/python/packages/hosting-responses/README.md @@ -34,6 +34,14 @@ Missing counters never borrow from another field or become invented zeros; an absent total alone is derived from known input and output counts. Usage that cannot form a consistent Responses shape is omitted. +Raised provider HTTP failures preserve the structured error `code` (falling +back through `type`, `title`, and the exception's `code` attribute) and +`message` (falling back through `detail` and the exception's `message` +attribute) supplied by the provider response body. This also applies when the +provider exception is wrapped by an Agent Framework exception. No error title +is inferred from the HTTP status. Providers and application backends own the +content and disclosure policy of the errors they return. + FastAPI/Starlette/Django/Azure Functions code owns route registration, authentication, status codes, response construction, and background work. diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py index 88f4655e3ac..0a23a562076 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py @@ -32,6 +32,7 @@ get_namespaced_value, ) from agent_framework._telemetry import mark_feature_used +from agent_framework.exceptions import _get_response_error_details # pyright: ignore[reportPrivateUsage] from agent_framework.observability import OBSERVABILITY_SETTINGS from agent_framework_hosting import AgentRunArgs from openai.types.responses import ( @@ -1523,6 +1524,7 @@ def apply_streamed_message_ids(response: dict[str, Any]) -> None: partial_contents = [ content for update in updates for content in update.contents if content.type == "text" and content.text ] + error_code, error_message = _get_response_error_details(exc) response_kwargs: dict[str, Any] = { "id": response_id, "object": "response", @@ -1535,13 +1537,17 @@ def apply_streamed_message_ids(response: dict[str, Any]) -> None: "tools": [], "metadata": {}, "error": { + # The OpenAI SDK model currently restricts error codes to its + # own known literals; validate the response shape first, then + # restore the provider-defined structured code below. "code": "server_error", - "message": _PUBLIC_STREAM_FAILURE_MESSAGE, + "message": error_message if error_message is not None else _PUBLIC_STREAM_FAILURE_MESSAGE, }, } if conversation_id is not None: response_kwargs["conversation"] = {"id": conversation_id} failed_response = _response_payload(OpenAIResponse(**response_kwargs)) + failed_response["error"]["code"] = error_code apply_streamed_message_ids(failed_response) yield event( "response.failed", diff --git a/python/packages/hosting-responses/pyproject.toml b/python/packages/hosting-responses/pyproject.toml index 52b72237da9..a5dbe5b32cd 100644 --- a/python/packages/hosting-responses/pyproject.toml +++ b/python/packages/hosting-responses/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.22.0,<2", + "agent-framework-core>=1.23.0,<2", "agent-framework-hosting==1.0.0a261015", "openai>=1.99.0,<4", ] diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py index 2d9276beff5..6db6aed62f0 100644 --- a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py +++ b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py @@ -21,6 +21,7 @@ ResponseStream, UsageDetails, ) +from agent_framework.exceptions import ChatClientException from agent_framework.observability import OBSERVABILITY_SETTINGS from openai.types.responses.response_usage import InputTokensDetails, ResponseUsage @@ -1471,6 +1472,46 @@ async def updates() -> AsyncIterator[AgentResponseUpdate]: assert "partial" in events[-1] assert private_error_detail not in "".join(events) + @pytest.mark.parametrize( + "status, code", + [ + (400, "invalid_request_error"), + (403, "permission_denied"), + (429, "token_rate_limit_exceeded"), + (503, "backend_overloaded"), + ], + ) + @pytest.mark.parametrize("wrapper", ["direct", "cause", "inner_exception"]) + async def test_stream_classifies_provider_http_failures(self, status: int, code: str, wrapper: str) -> None: + class ProviderError(Exception): + status_code = status + + def __init__(self) -> None: + super().__init__("rendered exception") + self.body = {"error": {"code": code, "message": "provider details"}} + + error: Exception = ProviderError() + if wrapper == "cause": + outer = ChatClientException("provider wrapper") + outer.__cause__ = error + error = outer + elif wrapper == "inner_exception": + error = ChatClientException("provider wrapper", inner_exception=error) + + async def updates() -> AsyncIterator[AgentResponseUpdate]: + yield AgentResponseUpdate(contents=[Content.from_text("partial")], role="assistant") + raise error + + stream = ResponseStream(updates(), finalizer=AgentResponse.from_updates) + events = [event async for event in responses_from_streaming_run(stream, response_id="resp_http_failure")] + payload = _sse_payload(events[-1]) + response = cast("dict[str, object]", payload["response"]) + failure = cast("dict[str, object]", response["error"]) + assert payload["type"] == "response.failed" + assert failure["code"] == code + assert failure["message"] == "provider details" + assert "partial" in events[-1] + async def test_failed_stream_preserves_partial_marked_refusal(self) -> None: async def updates() -> AsyncIterator[AgentResponseUpdate]: yield AgentResponseUpdate( From 86bd846b9def874fc53e38e07e8dec47f7326b34 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:25:18 -0700 Subject: [PATCH 2/4] refactor(python): normalize provider errors in clients Attach provider-neutral error details when connectors translate SDK failures so hosting never inspects provider exception bodies. --- .../core/agent_framework/exceptions.py | 75 +++++-------- .../core/tests/core/test_exceptions.py | 103 +++--------------- python/packages/foundry_hosting/README.md | 12 +- .../_responses.py | 21 +++- .../packages/foundry_hosting/pyproject.toml | 2 +- .../foundry_hosting/tests/test_responses.py | 43 ++++---- python/packages/hosting-responses/README.md | 13 +-- .../_parsing.py | 13 ++- .../packages/hosting-responses/pyproject.toml | 2 +- .../tests/hosting_responses/test_parsing.py | 38 ++++--- .../agent_framework_openai/_chat_client.py | 6 +- .../_chat_completion_client.py | 9 +- .../agent_framework_openai/_exceptions.py | 60 +++++++++- python/packages/openai/pyproject.toml | 2 +- .../openai/tests/openai/test_exceptions.py | 56 +++++++++- .../tests/openai/test_openai_chat_client.py | 3 + .../test_openai_chat_completion_client.py | 3 + 17 files changed, 262 insertions(+), 199 deletions(-) diff --git a/python/packages/core/agent_framework/exceptions.py b/python/packages/core/agent_framework/exceptions.py index 4f0dcdc67af..a35c0cd3729 100644 --- a/python/packages/core/agent_framework/exceptions.py +++ b/python/packages/core/agent_framework/exceptions.py @@ -7,8 +7,8 @@ """ import logging -from collections.abc import Mapping -from typing import Any, Literal, cast +from dataclasses import dataclass, field +from typing import Any, Literal logger = logging.getLogger("agent_framework") @@ -39,50 +39,6 @@ def __init__( super().__init__(message, *args) -def _get_response_error_details( # pyright: ignore[reportUnusedFunction] - exception: BaseException, -) -> tuple[str, str | None]: - """Preserve the provider's structured error code and message when available.""" - errors = [exception] - visited: set[int] = set() - while errors: - current = errors.pop() - if id(current) in visited: - continue - visited.add(id(current)) - status = getattr(current, "status_code", None) - if isinstance(status, int) and not isinstance(status, bool) and 400 <= status <= 599: - body = getattr(current, "body", None) - error_body: object = body - if isinstance(body, Mapping): - body_mapping = cast(Mapping[object, object], body) - nested_error = body_mapping.get("error") - if isinstance(nested_error, Mapping): - error_body = cast(Mapping[object, object], nested_error) - - code: object = None - message: object = None - if isinstance(error_body, Mapping): - error_mapping = cast(Mapping[object, object], error_body) - code = error_mapping.get("code") or error_mapping.get("type") or error_mapping.get("title") - message = error_mapping.get("message") or error_mapping.get("detail") - elif isinstance(error_body, str): - message = error_body - - if not isinstance(code, str) or not code: - code = getattr(current, "code", None) - if not isinstance(message, str) or not message: - message = getattr(current, "message", None) - if not isinstance(message, str) or not message: - message = str(current) or type(current).__name__ - return (code if isinstance(code, str) and code else "server_error"), message - if isinstance(current, AgentFrameworkException): - errors.extend(arg for arg in current.args if isinstance(arg, BaseException)) - if current.__cause__ is not None: - errors.append(current.__cause__) - return "server_error", None - - # region Agent Exceptions @@ -121,10 +77,35 @@ class AgentContentFilterException(AgentException): # region Chat Client Exceptions +def _empty_error_properties() -> dict[str, Any]: + return {} + + +@dataclass(frozen=True, slots=True) +class ChatClientErrorDetails: + """Provider-neutral structured details for a chat client failure.""" + + message: str + code: str | None = None + status_code: int | None = None + additional_properties: dict[str, Any] = field(default_factory=_empty_error_properties) + + class ChatClientException(AgentFrameworkException): """Base class for all chat client exceptions.""" - pass + def __init__( + self, + message: str, + inner_exception: Exception | None = None, + log_level: Literal[0] | Literal[10] | Literal[20] | Literal[30] | Literal[40] | Literal[50] | None = 10, + *args: Any, + error_details: ChatClientErrorDetails | None = None, + **kwargs: Any, + ) -> None: + """Create a chat client exception with optional provider-neutral error details.""" + self.error_details = error_details + super().__init__(message, inner_exception, log_level, *args, **kwargs) class ResponseInvalidatedException(ChatClientException): diff --git a/python/packages/core/tests/core/test_exceptions.py b/python/packages/core/tests/core/test_exceptions.py index 28fd39a0366..c367a080242 100644 --- a/python/packages/core/tests/core/test_exceptions.py +++ b/python/packages/core/tests/core/test_exceptions.py @@ -2,13 +2,11 @@ """Tests for AgentFrameworkException inner_exception handling.""" -import pytest - from agent_framework import AgentFrameworkException, ResponseInvalidatedException from agent_framework.exceptions import ( + ChatClientErrorDetails, ChatClientException, ChatClientInvalidResponseException, - _get_response_error_details, ) @@ -50,90 +48,23 @@ def test_response_invalidated_exception_is_public_and_picklable() -> None: assert str(restored.args[1]) == "provider stream failed" -@pytest.mark.parametrize( - "status, code", - [ - (400, "invalid_request_error"), - (403, "permission_denied"), - (429, "token_rate_limit_exceeded"), - (503, "backend_overloaded"), - ], -) -def test_response_error_details_follow_framework_inner_exception(status: int, code: str) -> None: - class ProviderError(Exception): - status_code = status - - def __init__(self) -> None: - super().__init__("rendered exception") - self.body = {"error": {"code": code, "message": "provider details"}} - - error = ChatClientException("wrapper", inner_exception=ProviderError()) - actual_code, message = _get_response_error_details(error) - assert actual_code == code - assert message == "provider details" - - -def test_response_error_details_support_problem_details_title_and_detail() -> None: - class ProviderError(Exception): - status_code = 429 - body = {"title": "usage_limit_reached", "detail": "Monthly usage limit reached."} - - assert _get_response_error_details(ProviderError()) == ( - "usage_limit_reached", - "Monthly usage limit reached.", - ) - - -def test_response_error_details_fall_back_to_exception_code_and_message() -> None: - class ProviderError(Exception): - status_code = 429 - body: dict[str, object] = {} - code = "token_quota_exceeded" - message = "Token quota exceeded." - - assert _get_response_error_details(ProviderError()) == ( - "token_quota_exceeded", - "Token quota exceeded.", +def test_chat_client_exception_preserves_structured_error_details() -> None: + details = ChatClientErrorDetails( + code="provider_error", + message="Provider details.", + status_code=429, + additional_properties={"type": "invalid_request_error", "request_id": "req_test"}, ) + error = ChatClientException("wrapper", inner_exception=RuntimeError("inner"), error_details=details) + actual = error.error_details + assert actual is details + assert actual is not None + assert actual.additional_properties == { + "type": "invalid_request_error", + "request_id": "req_test", + } -def test_response_error_details_handle_cycles_and_ignore_implicit_context() -> None: - class RateLimitError(Exception): - status_code = 429 - - outer = RuntimeError("outer") - inner = RuntimeError("inner") - outer.__cause__ = inner - inner.__cause__ = outer - inner.__context__ = RateLimitError("unrelated") - assert _get_response_error_details(outer) == ("server_error", None) - - -def test_response_error_details_respect_non_framework_wrapper_message() -> None: - class StorageConflictError(Exception): - status_code = 409 - - error = RuntimeError("Another request advanced this agent session; reload before writing.") - error.__cause__ = StorageConflictError("State store item already exists.") - assert _get_response_error_details(error) == ("server_error", None) - - -def test_response_error_details_preserve_outer_http_failure() -> None: - class ServerError(Exception): - status_code = 500 - body = {"error": {"code": "backend_overloaded", "message": "Backend is overloaded."}} - - class RateLimitError(Exception): - status_code = 429 - - error = ServerError("server failure") - error.__cause__ = RateLimitError("earlier failure") - assert _get_response_error_details(error) == ("backend_overloaded", "Backend is overloaded.") - - -@pytest.mark.parametrize("status", [True, "429", None, 200]) -def test_response_error_details_do_not_infer_unsupported_classifications(status: object) -> None: - class ProviderError(Exception): - status_code = status - assert _get_response_error_details(ProviderError("Error code: 429")) == ("server_error", None) +def test_chat_client_exception_defaults_to_no_error_details() -> None: + assert ChatClientException("wrapper").error_details is None diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index 294479b8629..5a70e2be02d 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -2,12 +2,12 @@ This package provides the integration of Agent Framework agents and workflows with the Foundry Agent Server, which can be hosted on Foundry infrastructure. -Responses failures preserve the structured error title and message supplied by -the provider response body through Agent Framework exception wrappers. The -title is selected from `code`, `type`, or `title`, and the message from -`message` or `detail`, with structured exception attributes used as fallbacks. -No error title is inferred from the HTTP status. Providers and application -backends own the content and disclosure policy of the errors they return. +Chat clients can attach provider-neutral `ChatClientErrorDetails` when they +translate SDK failures into `ChatClientException`. Responses failures preserve +those structured details, including provider-defined fields such as `type`, +`param`, and `request_id`. Foundry hosting does not inspect provider exception +bodies or infer an error title from HTTP status. Providers and application +backends own the content and disclosure policy of the details they attach. ## Agent instances and factories diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index ca9e4cf118e..aec88645125 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -54,7 +54,7 @@ from agent_framework._telemetry import mark_feature_used from agent_framework.exceptions import ( AgentFrameworkException, - _get_response_error_details, # pyright: ignore[reportPrivateUsage] + ChatClientException, ) from agent_framework.observability import OBSERVABILITY_SETTINGS from azure.ai.agentserver.core import get_request_context @@ -2373,11 +2373,24 @@ def _emit_failure( code = "server_error" message = ex.public_message else: - code, provider_message = _get_response_error_details(ex) - message = provider_message if provider_message is not None else _PUBLIC_STREAM_FAILURE_MESSAGE - yield response_event_stream.emit_failed( + error_details = ex.error_details if isinstance(ex, ChatClientException) else None + code = error_details.code if error_details is not None and error_details.code else "server_error" + message = error_details.message if error_details is not None else _PUBLIC_STREAM_FAILURE_MESSAGE + failed_event = response_event_stream.emit_failed( code=code, message=message, usage=tracker.usage if tracker is not None else None ) + if not isinstance(ex, _PublicResponseFailure) and isinstance(ex, ChatClientException): + error_details = ex.error_details + if error_details is not None: + failed_payload = cast(dict[str, Any], failed_event) + failed_response = cast(dict[str, Any], failed_payload["response"]) + failed_error = cast(dict[str, Any], failed_response["error"]) + failed_error.update(error_details.additional_properties) + if error_details.status_code is not None: + failed_error["status_code"] = error_details.status_code + failed_error["code"] = code + failed_error["message"] = message + yield failed_event # endregion ResponsesHostServer diff --git a/python/packages/foundry_hosting/pyproject.toml b/python/packages/foundry_hosting/pyproject.toml index f4f41a0b262..30a21b7aeb1 100644 --- a/python/packages/foundry_hosting/pyproject.toml +++ b/python/packages/foundry_hosting/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.23.0,<2", + "agent-framework-core>=1.24.0,<2", "azure-ai-agentserver-core>=2.1.0,<3", "azure-ai-agentserver-responses>=2.2.0b1,<3", "azure-ai-agentserver-invocations>=1.1.0,<2", diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index 3252f0167e0..00592307658 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -60,7 +60,7 @@ ) from agent_framework._mcp import _MCP_TOOL_RESULT_HOST_PAYLOAD_KEY # pyright: ignore[reportPrivateUsage] from agent_framework.ag_ui import AgentFrameworkAgent, InMemoryAGUIThreadSnapshotStore -from agent_framework.exceptions import ChatClientException +from agent_framework.exceptions import ChatClientErrorDetails, ChatClientException from agent_framework.observability import OBSERVABILITY_SETTINGS from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions, OpenAIContinuationToken from azure.ai.agentserver.core import FoundryAgentRequestContext, get_request_context @@ -129,22 +129,22 @@ (503, "backend_overloaded"), ], ) -@pytest.mark.parametrize("wrapper", ["direct", "cause", "inner_exception"]) -def test_failure_event_classifies_provider_http_failures(status: int, code: str, wrapper: str) -> None: - class ProviderError(Exception): - status_code = status - - def __init__(self) -> None: - super().__init__("rendered exception") - self.body = {"error": {"code": code, "message": _PRIVATE_ERROR_DETAIL}} - - error: Exception = ProviderError() - if wrapper == "cause": - outer = ChatClientException("provider wrapper") - outer.__cause__ = error - error = outer - elif wrapper == "inner_exception": - error = ChatClientException("provider wrapper", inner_exception=error) +def test_failure_event_classifies_provider_http_failures(status: int, code: str) -> None: + error = ChatClientException( + "provider wrapper", + error_details=ChatClientErrorDetails( + code=code, + message=_PRIVATE_ERROR_DETAIL, + status_code=status, + additional_properties={ + "code": code, + "message": _PRIVATE_ERROR_DETAIL, + "type": "invalid_request_error", + "param": "input", + "request_id": "req_test", + }, + ), + ) stream = ResponseEventStream(response_id="resp_http_failure") stream.emit_created() @@ -162,8 +162,13 @@ def __init__(self) -> None: assert terminal["type"] == "response.failed" failure = terminal["response"]["error"] assert failure is not None - assert failure["code"] == code - assert failure["message"] == _PRIVATE_ERROR_DETAIL + failure_mapping = cast(dict[str, Any], failure) + assert failure_mapping["code"] == code + assert failure_mapping["message"] == _PRIVATE_ERROR_DETAIL + assert failure_mapping["type"] == "invalid_request_error" + assert failure_mapping["param"] == "input" + assert failure_mapping["request_id"] == "req_test" + assert failure_mapping["status_code"] == status @pytest.fixture(scope="session", autouse=True) diff --git a/python/packages/hosting-responses/README.md b/python/packages/hosting-responses/README.md index 620c5c8d395..c7de81b27cb 100644 --- a/python/packages/hosting-responses/README.md +++ b/python/packages/hosting-responses/README.md @@ -34,13 +34,12 @@ Missing counters never borrow from another field or become invented zeros; an absent total alone is derived from known input and output counts. Usage that cannot form a consistent Responses shape is omitted. -Raised provider HTTP failures preserve the structured error `code` (falling -back through `type`, `title`, and the exception's `code` attribute) and -`message` (falling back through `detail` and the exception's `message` -attribute) supplied by the provider response body. This also applies when the -provider exception is wrapped by an Agent Framework exception. No error title -is inferred from the HTTP status. Providers and application backends own the -content and disclosure policy of the errors they return. +Chat clients can attach provider-neutral `ChatClientErrorDetails` when they +translate SDK failures into `ChatClientException`. Raised chat-client failures +preserve those structured details, including provider-defined fields such as +`type`, `param`, and `request_id`. Hosting does not inspect provider exception +bodies or infer an error title from HTTP status. Providers and application +backends own the content and disclosure policy of the details they attach. FastAPI/Starlette/Django/Azure Functions code owns route registration, authentication, status codes, response construction, and background work. diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py index 0a23a562076..10ef2a8de1b 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py @@ -32,7 +32,7 @@ get_namespaced_value, ) from agent_framework._telemetry import mark_feature_used -from agent_framework.exceptions import _get_response_error_details # pyright: ignore[reportPrivateUsage] +from agent_framework.exceptions import ChatClientException from agent_framework.observability import OBSERVABILITY_SETTINGS from agent_framework_hosting import AgentRunArgs from openai.types.responses import ( @@ -1524,7 +1524,9 @@ def apply_streamed_message_ids(response: dict[str, Any]) -> None: partial_contents = [ content for update in updates for content in update.contents if content.type == "text" and content.text ] - error_code, error_message = _get_response_error_details(exc) + error_details = exc.error_details if isinstance(exc, ChatClientException) else None + error_code = error_details.code if error_details is not None and error_details.code else "server_error" + error_message = error_details.message if error_details is not None else _PUBLIC_STREAM_FAILURE_MESSAGE response_kwargs: dict[str, Any] = { "id": response_id, "object": "response", @@ -1541,13 +1543,18 @@ def apply_streamed_message_ids(response: dict[str, Any]) -> None: # own known literals; validate the response shape first, then # restore the provider-defined structured code below. "code": "server_error", - "message": error_message if error_message is not None else _PUBLIC_STREAM_FAILURE_MESSAGE, + "message": error_message, }, } if conversation_id is not None: response_kwargs["conversation"] = {"id": conversation_id} failed_response = _response_payload(OpenAIResponse(**response_kwargs)) + if error_details is not None: + failed_response["error"].update(error_details.additional_properties) + if error_details.status_code is not None: + failed_response["error"]["status_code"] = error_details.status_code failed_response["error"]["code"] = error_code + failed_response["error"]["message"] = error_message apply_streamed_message_ids(failed_response) yield event( "response.failed", diff --git a/python/packages/hosting-responses/pyproject.toml b/python/packages/hosting-responses/pyproject.toml index a5dbe5b32cd..6541cd3b2ff 100644 --- a/python/packages/hosting-responses/pyproject.toml +++ b/python/packages/hosting-responses/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.23.0,<2", + "agent-framework-core>=1.24.0,<2", "agent-framework-hosting==1.0.0a261015", "openai>=1.99.0,<4", ] diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py index 6db6aed62f0..6b5a7a44a0b 100644 --- a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py +++ b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py @@ -21,7 +21,7 @@ ResponseStream, UsageDetails, ) -from agent_framework.exceptions import ChatClientException +from agent_framework.exceptions import ChatClientErrorDetails, ChatClientException from agent_framework.observability import OBSERVABILITY_SETTINGS from openai.types.responses.response_usage import InputTokensDetails, ResponseUsage @@ -1481,22 +1481,22 @@ async def updates() -> AsyncIterator[AgentResponseUpdate]: (503, "backend_overloaded"), ], ) - @pytest.mark.parametrize("wrapper", ["direct", "cause", "inner_exception"]) - async def test_stream_classifies_provider_http_failures(self, status: int, code: str, wrapper: str) -> None: - class ProviderError(Exception): - status_code = status - - def __init__(self) -> None: - super().__init__("rendered exception") - self.body = {"error": {"code": code, "message": "provider details"}} - - error: Exception = ProviderError() - if wrapper == "cause": - outer = ChatClientException("provider wrapper") - outer.__cause__ = error - error = outer - elif wrapper == "inner_exception": - error = ChatClientException("provider wrapper", inner_exception=error) + async def test_stream_classifies_provider_http_failures(self, status: int, code: str) -> None: + error = ChatClientException( + "provider wrapper", + error_details=ChatClientErrorDetails( + code=code, + message="provider details", + status_code=status, + additional_properties={ + "code": code, + "message": "provider details", + "type": "invalid_request_error", + "param": "input", + "request_id": "req_test", + }, + ), + ) async def updates() -> AsyncIterator[AgentResponseUpdate]: yield AgentResponseUpdate(contents=[Content.from_text("partial")], role="assistant") @@ -1510,6 +1510,10 @@ async def updates() -> AsyncIterator[AgentResponseUpdate]: assert payload["type"] == "response.failed" assert failure["code"] == code assert failure["message"] == "provider details" + assert failure["type"] == "invalid_request_error" + assert failure["param"] == "input" + assert failure["request_id"] == "req_test" + assert failure["status_code"] == status assert "partial" in events[-1] async def test_failed_stream_preserves_partial_marked_refusal(self) -> None: diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index a5c18ebda85..23d6ed4ee4d 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -116,7 +116,10 @@ from openai.types.responses.web_search_tool_param import WebSearchToolParam from pydantic import BaseModel, TypeAdapter, ValidationError -from ._exceptions import OpenAIContentFilterException +from ._exceptions import ( + OpenAIContentFilterException, + _get_openai_error_details, # pyright: ignore[reportPrivateUsage] +) from ._feature_usage import FeatureIndex from ._shared import ( AzureTokenProvider, @@ -854,6 +857,7 @@ def _handle_request_error(self, ex: Exception) -> NoReturn: azure_endpoint=self.azure_endpoint, ), inner_exception=ex, + error_details=_get_openai_error_details(ex), ) from ex @override diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index 92be0f53933..ddb5f3b3af9 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -62,7 +62,10 @@ from openai.types.chat.completion_create_params import WebSearchOptions from pydantic import BaseModel -from ._exceptions import OpenAIContentFilterException +from ._exceptions import ( + OpenAIContentFilterException, + _get_openai_error_details, # pyright: ignore[reportPrivateUsage] +) from ._feature_usage import FeatureIndex from ._shared import ( PROMPT_CACHE_BREAKPOINT_KEY, @@ -698,6 +701,7 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: azure_endpoint=self.azure_endpoint, ), inner_exception=ex, + error_details=_get_openai_error_details(ex), ) from ex except Exception as ex: raise ChatClientException( @@ -706,6 +710,7 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: azure_endpoint=self.azure_endpoint, ), inner_exception=ex, + error_details=_get_openai_error_details(ex), ) from ex return self._build_response_stream(_stream(), response_format=options.get("response_format")) @@ -734,6 +739,7 @@ async def _get_response() -> ChatResponse: azure_endpoint=self.azure_endpoint, ), inner_exception=ex, + error_details=_get_openai_error_details(ex), ) from ex except Exception as ex: raise ChatClientException( @@ -742,6 +748,7 @@ async def _get_response() -> ChatResponse: azure_endpoint=self.azure_endpoint, ), inner_exception=ex, + error_details=_get_openai_error_details(ex), ) from ex return _get_response() diff --git a/python/packages/openai/agent_framework_openai/_exceptions.py b/python/packages/openai/agent_framework_openai/_exceptions.py index 106430ab5f2..d63b4ceaadf 100644 --- a/python/packages/openai/agent_framework_openai/_exceptions.py +++ b/python/packages/openai/agent_framework_openai/_exceptions.py @@ -2,12 +2,64 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from enum import Enum -from typing import Any - -from agent_framework.exceptions import ChatClientContentFilterException -from openai import BadRequestError +from typing import Any, cast + +from agent_framework.exceptions import ChatClientContentFilterException, ChatClientErrorDetails +from openai import APIStatusError, BadRequestError + + +def _first_nonempty_string(mapping: Mapping[object, object], *keys: str) -> str | None: + """Return the first nonempty string stored under ``keys``.""" + for key in keys: + value = mapping.get(key) + if isinstance(value, str) and value: + return value + return None + + +def _get_openai_error_details( # pyright: ignore[reportUnusedFunction] + error: Exception, +) -> ChatClientErrorDetails | None: + """Normalize one OpenAI SDK status error into provider-neutral details.""" + if not isinstance(error, APIStatusError): + return None + try: + body = error.body + error_body: object = body + if isinstance(body, Mapping): + body_mapping = cast(Mapping[object, object], body) + nested = body_mapping.get("error") + if isinstance(nested, Mapping): + error_body = cast(Mapping[object, object], nested) + + additional_properties: dict[str, Any] = {} + code: str | None = None + message: str | None = None + if isinstance(error_body, Mapping): + error_mapping = cast(Mapping[object, object], error_body) + additional_properties = {key: value for key, value in error_mapping.items() if isinstance(key, str)} + code = _first_nonempty_string(error_mapping, "code", "type", "title") + message = _first_nonempty_string(error_mapping, "message", "detail") + elif isinstance(error_body, str) and error_body: + message = error_body + + if code is None and isinstance(error.code, str) and error.code: + code = error.code + if message is None and isinstance(error.message, str) and error.message: + message = error.message + if message is None: + return None + return ChatClientErrorDetails( + code=code, + message=message, + status_code=error.status_code, + additional_properties=additional_properties, + ) + except Exception: + return None class ContentFilterResultSeverity(Enum): diff --git a/python/packages/openai/pyproject.toml b/python/packages/openai/pyproject.toml index 09625125544..ee03070f7ed 100644 --- a/python/packages/openai/pyproject.toml +++ b/python/packages/openai/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.22.0,<2", + "agent-framework-core>=1.24.0,<2", "openai>=2.25.0,<4", ] diff --git a/python/packages/openai/tests/openai/test_exceptions.py b/python/packages/openai/tests/openai/test_exceptions.py index 880faa2e24e..b837b1a3d34 100644 --- a/python/packages/openai/tests/openai/test_exceptions.py +++ b/python/packages/openai/tests/openai/test_exceptions.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Iterator, Mapping from typing import Any from unittest.mock import MagicMock @@ -9,7 +10,12 @@ from openai import BadRequestError from agent_framework_openai import OpenAIContentFilterException -from agent_framework_openai._exceptions import ContentFilterCodes, ContentFilterResult, ContentFilterResultSeverity +from agent_framework_openai._exceptions import ( + ContentFilterCodes, + ContentFilterResult, + ContentFilterResultSeverity, + _get_openai_error_details, +) @pytest.mark.parametrize( @@ -41,3 +47,51 @@ def test_content_filter_exception_codes(inner_code: str | None, expected_code: s assert exception.content_filter_result == { "self_harm": ContentFilterResult(filtered=True, severity=ContentFilterResultSeverity.MEDIUM), } + + +def test_openai_error_details_preserve_structured_body() -> None: + error = BadRequestError( + "rendered error", + response=MagicMock(status_code=400), + body={ + "error": { + "code": "missing_required_parameter", + "message": "Input is required.", + "type": "invalid_request_error", + "param": "input", + "request_id": "req_test", + } + }, + ) + details = _get_openai_error_details(error) + + assert details is not None + assert details.code == "missing_required_parameter" + assert details.message == "Input is required." + assert details.status_code == 400 + assert details.additional_properties == { + "code": "missing_required_parameter", + "message": "Input is required.", + "type": "invalid_request_error", + "param": "input", + "request_id": "req_test", + } + + +def test_openai_error_details_fail_closed_when_mapping_access_raises() -> None: + class BrokenMapping(Mapping[str, object]): + def __getitem__(self, key: str) -> object: + raise RuntimeError("broken body") + + def __iter__(self) -> Iterator[str]: + return iter(("error",)) + + def __len__(self) -> int: + return 1 + + error = BadRequestError( + "rendered error", + response=MagicMock(status_code=400), + body=BrokenMapping(), + ) + assert _get_openai_error_details(error) is None diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 15cadc9d9b9..f26d40e461f 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -7987,6 +7987,9 @@ async def test_service_response_exception_includes_original_error_details() -> N exception_message = str(exc_info.value) assert "service failed to complete the prompt:" in exception_message assert original_error_message in exception_message + assert exc_info.value.error_details is not None + assert exc_info.value.error_details.code == "rate_limit" + assert exc_info.value.error_details.message == original_error_message async def test_get_response_streaming_with_response_format() -> None: diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index 7d5fc75b077..9820e40fd9f 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -410,6 +410,9 @@ async def test_exception_message_includes_original_error_details() -> None: exception_message = str(exc_info.value) assert "service failed to complete the prompt:" in exception_message assert original_error_message in exception_message + assert exc_info.value.error_details is not None + assert exc_info.value.error_details.code == "invalid_request" + assert exc_info.value.error_details.message == original_error_message def test_chat_response_content_order_text_before_tool_calls( From 0cbec8e8d90ad48f1aa29258ad16d34c0caed0fb Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:41:53 -0700 Subject: [PATCH 3/4] refactor(python): confine provider errors to hosting Reuse the original SDK exception already carried by ChatClientException and keep provider-specific handling out of core and connectors. --- .../core/agent_framework/exceptions.py | 28 +-------- .../core/tests/core/test_exceptions.py | 28 +-------- python/packages/foundry_hosting/README.md | 13 ++-- .../_responses.py | 52 ++++++++++++---- .../packages/foundry_hosting/pyproject.toml | 2 +- .../foundry_hosting/tests/test_responses.py | 31 ++++++---- python/packages/hosting-responses/README.md | 12 ++-- .../_parsing.py | 50 +++++++++++++--- .../packages/hosting-responses/pyproject.toml | 2 +- .../tests/hosting_responses/test_parsing.py | 31 ++++++---- .../agent_framework_openai/_chat_client.py | 6 +- .../_chat_completion_client.py | 9 +-- .../agent_framework_openai/_exceptions.py | 60 ++----------------- python/packages/openai/pyproject.toml | 2 +- .../openai/tests/openai/test_exceptions.py | 56 +---------------- .../tests/openai/test_openai_chat_client.py | 3 - .../test_openai_chat_completion_client.py | 3 - 17 files changed, 146 insertions(+), 242 deletions(-) diff --git a/python/packages/core/agent_framework/exceptions.py b/python/packages/core/agent_framework/exceptions.py index a35c0cd3729..a908b5bce5d 100644 --- a/python/packages/core/agent_framework/exceptions.py +++ b/python/packages/core/agent_framework/exceptions.py @@ -7,7 +7,6 @@ """ import logging -from dataclasses import dataclass, field from typing import Any, Literal logger = logging.getLogger("agent_framework") @@ -77,35 +76,10 @@ class AgentContentFilterException(AgentException): # region Chat Client Exceptions -def _empty_error_properties() -> dict[str, Any]: - return {} - - -@dataclass(frozen=True, slots=True) -class ChatClientErrorDetails: - """Provider-neutral structured details for a chat client failure.""" - - message: str - code: str | None = None - status_code: int | None = None - additional_properties: dict[str, Any] = field(default_factory=_empty_error_properties) - - class ChatClientException(AgentFrameworkException): """Base class for all chat client exceptions.""" - def __init__( - self, - message: str, - inner_exception: Exception | None = None, - log_level: Literal[0] | Literal[10] | Literal[20] | Literal[30] | Literal[40] | Literal[50] | None = 10, - *args: Any, - error_details: ChatClientErrorDetails | None = None, - **kwargs: Any, - ) -> None: - """Create a chat client exception with optional provider-neutral error details.""" - self.error_details = error_details - super().__init__(message, inner_exception, log_level, *args, **kwargs) + pass class ResponseInvalidatedException(ChatClientException): diff --git a/python/packages/core/tests/core/test_exceptions.py b/python/packages/core/tests/core/test_exceptions.py index c367a080242..1605268cc56 100644 --- a/python/packages/core/tests/core/test_exceptions.py +++ b/python/packages/core/tests/core/test_exceptions.py @@ -3,11 +3,7 @@ """Tests for AgentFrameworkException inner_exception handling.""" from agent_framework import AgentFrameworkException, ResponseInvalidatedException -from agent_framework.exceptions import ( - ChatClientErrorDetails, - ChatClientException, - ChatClientInvalidResponseException, -) +from agent_framework.exceptions import ChatClientException, ChatClientInvalidResponseException def test_exception_with_inner_exception(): @@ -46,25 +42,3 @@ def test_response_invalidated_exception_is_public_and_picklable() -> None: assert restored.args[0] == "partial response output was invalidated" assert isinstance(restored.args[1], RuntimeError) assert str(restored.args[1]) == "provider stream failed" - - -def test_chat_client_exception_preserves_structured_error_details() -> None: - details = ChatClientErrorDetails( - code="provider_error", - message="Provider details.", - status_code=429, - additional_properties={"type": "invalid_request_error", "request_id": "req_test"}, - ) - error = ChatClientException("wrapper", inner_exception=RuntimeError("inner"), error_details=details) - actual = error.error_details - - assert actual is details - assert actual is not None - assert actual.additional_properties == { - "type": "invalid_request_error", - "request_id": "req_test", - } - - -def test_chat_client_exception_defaults_to_no_error_details() -> None: - assert ChatClientException("wrapper").error_details is None diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index 5a70e2be02d..1d3ad173cf9 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -2,12 +2,13 @@ This package provides the integration of Agent Framework agents and workflows with the Foundry Agent Server, which can be hosted on Foundry infrastructure. -Chat clients can attach provider-neutral `ChatClientErrorDetails` when they -translate SDK failures into `ChatClientException`. Responses failures preserve -those structured details, including provider-defined fields such as `type`, -`param`, and `request_id`. Foundry hosting does not inspect provider exception -bodies or infer an error title from HTTP status. Providers and application -backends own the content and disclosure policy of the details they attach. +Chat-client exceptions retain their original SDK exception through the standard +`inner_exception`/`__cause__` chain. Foundry Responses hosting copies the SDK's +existing `code`, `message`, `status_code`, and body fields without interpreting +the body or inferring an error title from HTTP status. Non-chat-client and +malformed errors retain the generic sanitized fallback. Providers and +application backends own the content and disclosure policy of the errors they +return. ## Agent instances and factories diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index aec88645125..75d84ed9920 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -152,6 +152,38 @@ _AGENT_SOURCE_UNSET = object() +def _provider_error_from_chat_client(exception: BaseException) -> dict[str, Any] | None: + """Copy the original SDK error already carried by a chat client exception.""" + if not isinstance(exception, ChatClientException): + return None + inner: Exception | None = exception.__cause__ if isinstance(exception.__cause__, Exception) else None + if inner is None: + inner = next((arg for arg in exception.args[1:] if isinstance(arg, Exception)), None) + if inner is None: + return None + try: + code = getattr(inner, "code", None) + message = getattr(inner, "message", None) + status_code = getattr(inner, "status_code", None) + body = getattr(inner, "body", None) + if not isinstance(message, str) or not message: + return None + payload = ( + {key: value for key, value in cast(Mapping[object, object], body).items() if isinstance(key, str)} + if isinstance(body, Mapping) + else {} + ) + payload.setdefault("code", code if isinstance(code, str) and code else "server_error") + payload.setdefault("message", message) + if not isinstance(payload["code"], str) or not isinstance(payload["message"], str): + return None + if isinstance(status_code, int) and not isinstance(status_code, bool): + payload["status_code"] = status_code + return payload + except Exception: + return None + + class _PublicResponseFailure(Exception): """Mark a vetted failure message as safe for client-visible serialization.""" @@ -2373,23 +2405,23 @@ def _emit_failure( code = "server_error" message = ex.public_message else: - error_details = ex.error_details if isinstance(ex, ChatClientException) else None - code = error_details.code if error_details is not None and error_details.code else "server_error" - message = error_details.message if error_details is not None else _PUBLIC_STREAM_FAILURE_MESSAGE + provider_error = _provider_error_from_chat_client(ex) + code = cast(str, provider_error["code"]) if provider_error is not None else "server_error" + message = ( + cast(str, provider_error["message"]) if provider_error is not None else _PUBLIC_STREAM_FAILURE_MESSAGE + ) failed_event = response_event_stream.emit_failed( code=code, message=message, usage=tracker.usage if tracker is not None else None ) if not isinstance(ex, _PublicResponseFailure) and isinstance(ex, ChatClientException): - error_details = ex.error_details - if error_details is not None: + provider_error = _provider_error_from_chat_client(ex) + if provider_error is not None: failed_payload = cast(dict[str, Any], failed_event) failed_response = cast(dict[str, Any], failed_payload["response"]) failed_error = cast(dict[str, Any], failed_response["error"]) - failed_error.update(error_details.additional_properties) - if error_details.status_code is not None: - failed_error["status_code"] = error_details.status_code - failed_error["code"] = code - failed_error["message"] = message + failed_error.update(provider_error) + failed_error.setdefault("code", code) + failed_error.setdefault("message", message) yield failed_event diff --git a/python/packages/foundry_hosting/pyproject.toml b/python/packages/foundry_hosting/pyproject.toml index 30a21b7aeb1..f4f41a0b262 100644 --- a/python/packages/foundry_hosting/pyproject.toml +++ b/python/packages/foundry_hosting/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.24.0,<2", + "agent-framework-core>=1.23.0,<2", "azure-ai-agentserver-core>=2.1.0,<3", "azure-ai-agentserver-responses>=2.2.0b1,<3", "azure-ai-agentserver-invocations>=1.1.0,<2", diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index 00592307658..75ef60462ba 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -60,7 +60,7 @@ ) from agent_framework._mcp import _MCP_TOOL_RESULT_HOST_PAYLOAD_KEY # pyright: ignore[reportPrivateUsage] from agent_framework.ag_ui import AgentFrameworkAgent, InMemoryAGUIThreadSnapshotStore -from agent_framework.exceptions import ChatClientErrorDetails, ChatClientException +from agent_framework.exceptions import ChatClientException from agent_framework.observability import OBSERVABILITY_SETTINGS from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions, OpenAIContinuationToken from azure.ai.agentserver.core import FoundryAgentRequestContext, get_request_context @@ -129,22 +129,29 @@ (503, "backend_overloaded"), ], ) -def test_failure_event_classifies_provider_http_failures(status: int, code: str) -> None: - error = ChatClientException( - "provider wrapper", - error_details=ChatClientErrorDetails( - code=code, - message=_PRIVATE_ERROR_DETAIL, - status_code=status, - additional_properties={ +@pytest.mark.parametrize("wrapper", ["cause", "inner_exception"]) +def test_failure_event_classifies_provider_http_failures(status: int, code: str, wrapper: str) -> None: + class ProviderError(Exception): + status_code = status + message = "rendered SDK exception" + + def __init__(self) -> None: + super().__init__(self.message) + self.code = code + self.body = { "code": code, "message": _PRIVATE_ERROR_DETAIL, "type": "invalid_request_error", "param": "input", "request_id": "req_test", - }, - ), - ) + } + + provider_error = ProviderError() + if wrapper == "cause": + error = ChatClientException("provider wrapper") + error.__cause__ = provider_error + else: + error = ChatClientException("provider wrapper", inner_exception=provider_error) stream = ResponseEventStream(response_id="resp_http_failure") stream.emit_created() diff --git a/python/packages/hosting-responses/README.md b/python/packages/hosting-responses/README.md index c7de81b27cb..496619f009f 100644 --- a/python/packages/hosting-responses/README.md +++ b/python/packages/hosting-responses/README.md @@ -34,12 +34,12 @@ Missing counters never borrow from another field or become invented zeros; an absent total alone is derived from known input and output counts. Usage that cannot form a consistent Responses shape is omitted. -Chat clients can attach provider-neutral `ChatClientErrorDetails` when they -translate SDK failures into `ChatClientException`. Raised chat-client failures -preserve those structured details, including provider-defined fields such as -`type`, `param`, and `request_id`. Hosting does not inspect provider exception -bodies or infer an error title from HTTP status. Providers and application -backends own the content and disclosure policy of the details they attach. +Chat-client exceptions retain their original SDK exception through the standard +`inner_exception`/`__cause__` chain. Responses hosting copies the SDK's existing +`code`, `message`, `status_code`, and body fields without interpreting the body +or inferring an error title from HTTP status. Non-chat-client and malformed +errors retain the generic sanitized fallback. Providers and application +backends own the content and disclosure policy of the errors they return. FastAPI/Starlette/Django/Azure Functions code owns route registration, authentication, status codes, response construction, and background work. diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py index 10ef2a8de1b..cb527432345 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py @@ -82,6 +82,38 @@ _STREAMING_TERMINAL_STATUSES = frozenset({"completed", "failed", "incomplete"}) +def _provider_error_from_chat_client(exception: Exception) -> dict[str, Any] | None: + """Copy the original SDK error already carried by a chat client exception.""" + if not isinstance(exception, ChatClientException): + return None + inner: Exception | None = exception.__cause__ if isinstance(exception.__cause__, Exception) else None + if inner is None: + inner = next((arg for arg in exception.args[1:] if isinstance(arg, Exception)), None) + if inner is None: + return None + try: + code = getattr(inner, "code", None) + message = getattr(inner, "message", None) + status_code = getattr(inner, "status_code", None) + body = getattr(inner, "body", None) + if not isinstance(message, str) or not message: + return None + payload = ( + {key: value for key, value in cast(Mapping[object, object], body).items() if isinstance(key, str)} + if isinstance(body, Mapping) + else {} + ) + payload.setdefault("code", code if isinstance(code, str) and code else "server_error") + payload.setdefault("message", message) + if not isinstance(payload["code"], str) or not isinstance(payload["message"], str): + return None + if isinstance(status_code, int) and not isinstance(status_code, bool): + payload["status_code"] = status_code + return payload + except Exception: + return None + + def _log_stream_failure(response_id: str, exc: Exception) -> None: """Log a correlated stream failure under the framework's sensitive-data policy.""" if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: @@ -1524,9 +1556,11 @@ def apply_streamed_message_ids(response: dict[str, Any]) -> None: partial_contents = [ content for update in updates for content in update.contents if content.type == "text" and content.text ] - error_details = exc.error_details if isinstance(exc, ChatClientException) else None - error_code = error_details.code if error_details is not None and error_details.code else "server_error" - error_message = error_details.message if error_details is not None else _PUBLIC_STREAM_FAILURE_MESSAGE + provider_error = _provider_error_from_chat_client(exc) + error_code = cast(str, provider_error["code"]) if provider_error is not None else "server_error" + error_message = ( + cast(str, provider_error["message"]) if provider_error is not None else _PUBLIC_STREAM_FAILURE_MESSAGE + ) response_kwargs: dict[str, Any] = { "id": response_id, "object": "response", @@ -1549,12 +1583,10 @@ def apply_streamed_message_ids(response: dict[str, Any]) -> None: if conversation_id is not None: response_kwargs["conversation"] = {"id": conversation_id} failed_response = _response_payload(OpenAIResponse(**response_kwargs)) - if error_details is not None: - failed_response["error"].update(error_details.additional_properties) - if error_details.status_code is not None: - failed_response["error"]["status_code"] = error_details.status_code - failed_response["error"]["code"] = error_code - failed_response["error"]["message"] = error_message + if provider_error is not None: + failed_response["error"].update(provider_error) + failed_response["error"].setdefault("code", error_code) + failed_response["error"].setdefault("message", error_message) apply_streamed_message_ids(failed_response) yield event( "response.failed", diff --git a/python/packages/hosting-responses/pyproject.toml b/python/packages/hosting-responses/pyproject.toml index 6541cd3b2ff..52b72237da9 100644 --- a/python/packages/hosting-responses/pyproject.toml +++ b/python/packages/hosting-responses/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.24.0,<2", + "agent-framework-core>=1.22.0,<2", "agent-framework-hosting==1.0.0a261015", "openai>=1.99.0,<4", ] diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py index 6b5a7a44a0b..295a3560e80 100644 --- a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py +++ b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py @@ -21,7 +21,7 @@ ResponseStream, UsageDetails, ) -from agent_framework.exceptions import ChatClientErrorDetails, ChatClientException +from agent_framework.exceptions import ChatClientException from agent_framework.observability import OBSERVABILITY_SETTINGS from openai.types.responses.response_usage import InputTokensDetails, ResponseUsage @@ -1481,22 +1481,29 @@ async def updates() -> AsyncIterator[AgentResponseUpdate]: (503, "backend_overloaded"), ], ) - async def test_stream_classifies_provider_http_failures(self, status: int, code: str) -> None: - error = ChatClientException( - "provider wrapper", - error_details=ChatClientErrorDetails( - code=code, - message="provider details", - status_code=status, - additional_properties={ + @pytest.mark.parametrize("wrapper", ["cause", "inner_exception"]) + async def test_stream_classifies_provider_http_failures(self, status: int, code: str, wrapper: str) -> None: + class ProviderError(Exception): + status_code = status + message = "rendered SDK exception" + + def __init__(self) -> None: + super().__init__(self.message) + self.code = code + self.body = { "code": code, "message": "provider details", "type": "invalid_request_error", "param": "input", "request_id": "req_test", - }, - ), - ) + } + + provider_error = ProviderError() + if wrapper == "cause": + error = ChatClientException("provider wrapper") + error.__cause__ = provider_error + else: + error = ChatClientException("provider wrapper", inner_exception=provider_error) async def updates() -> AsyncIterator[AgentResponseUpdate]: yield AgentResponseUpdate(contents=[Content.from_text("partial")], role="assistant") diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 23d6ed4ee4d..a5c18ebda85 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -116,10 +116,7 @@ from openai.types.responses.web_search_tool_param import WebSearchToolParam from pydantic import BaseModel, TypeAdapter, ValidationError -from ._exceptions import ( - OpenAIContentFilterException, - _get_openai_error_details, # pyright: ignore[reportPrivateUsage] -) +from ._exceptions import OpenAIContentFilterException from ._feature_usage import FeatureIndex from ._shared import ( AzureTokenProvider, @@ -857,7 +854,6 @@ def _handle_request_error(self, ex: Exception) -> NoReturn: azure_endpoint=self.azure_endpoint, ), inner_exception=ex, - error_details=_get_openai_error_details(ex), ) from ex @override diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index ddb5f3b3af9..92be0f53933 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -62,10 +62,7 @@ from openai.types.chat.completion_create_params import WebSearchOptions from pydantic import BaseModel -from ._exceptions import ( - OpenAIContentFilterException, - _get_openai_error_details, # pyright: ignore[reportPrivateUsage] -) +from ._exceptions import OpenAIContentFilterException from ._feature_usage import FeatureIndex from ._shared import ( PROMPT_CACHE_BREAKPOINT_KEY, @@ -701,7 +698,6 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: azure_endpoint=self.azure_endpoint, ), inner_exception=ex, - error_details=_get_openai_error_details(ex), ) from ex except Exception as ex: raise ChatClientException( @@ -710,7 +706,6 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: azure_endpoint=self.azure_endpoint, ), inner_exception=ex, - error_details=_get_openai_error_details(ex), ) from ex return self._build_response_stream(_stream(), response_format=options.get("response_format")) @@ -739,7 +734,6 @@ async def _get_response() -> ChatResponse: azure_endpoint=self.azure_endpoint, ), inner_exception=ex, - error_details=_get_openai_error_details(ex), ) from ex except Exception as ex: raise ChatClientException( @@ -748,7 +742,6 @@ async def _get_response() -> ChatResponse: azure_endpoint=self.azure_endpoint, ), inner_exception=ex, - error_details=_get_openai_error_details(ex), ) from ex return _get_response() diff --git a/python/packages/openai/agent_framework_openai/_exceptions.py b/python/packages/openai/agent_framework_openai/_exceptions.py index d63b4ceaadf..106430ab5f2 100644 --- a/python/packages/openai/agent_framework_openai/_exceptions.py +++ b/python/packages/openai/agent_framework_openai/_exceptions.py @@ -2,64 +2,12 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from enum import Enum -from typing import Any, cast - -from agent_framework.exceptions import ChatClientContentFilterException, ChatClientErrorDetails -from openai import APIStatusError, BadRequestError - - -def _first_nonempty_string(mapping: Mapping[object, object], *keys: str) -> str | None: - """Return the first nonempty string stored under ``keys``.""" - for key in keys: - value = mapping.get(key) - if isinstance(value, str) and value: - return value - return None - - -def _get_openai_error_details( # pyright: ignore[reportUnusedFunction] - error: Exception, -) -> ChatClientErrorDetails | None: - """Normalize one OpenAI SDK status error into provider-neutral details.""" - if not isinstance(error, APIStatusError): - return None - try: - body = error.body - error_body: object = body - if isinstance(body, Mapping): - body_mapping = cast(Mapping[object, object], body) - nested = body_mapping.get("error") - if isinstance(nested, Mapping): - error_body = cast(Mapping[object, object], nested) - - additional_properties: dict[str, Any] = {} - code: str | None = None - message: str | None = None - if isinstance(error_body, Mapping): - error_mapping = cast(Mapping[object, object], error_body) - additional_properties = {key: value for key, value in error_mapping.items() if isinstance(key, str)} - code = _first_nonempty_string(error_mapping, "code", "type", "title") - message = _first_nonempty_string(error_mapping, "message", "detail") - elif isinstance(error_body, str) and error_body: - message = error_body - - if code is None and isinstance(error.code, str) and error.code: - code = error.code - if message is None and isinstance(error.message, str) and error.message: - message = error.message - if message is None: - return None - return ChatClientErrorDetails( - code=code, - message=message, - status_code=error.status_code, - additional_properties=additional_properties, - ) - except Exception: - return None +from typing import Any + +from agent_framework.exceptions import ChatClientContentFilterException +from openai import BadRequestError class ContentFilterResultSeverity(Enum): diff --git a/python/packages/openai/pyproject.toml b/python/packages/openai/pyproject.toml index ee03070f7ed..09625125544 100644 --- a/python/packages/openai/pyproject.toml +++ b/python/packages/openai/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.24.0,<2", + "agent-framework-core>=1.22.0,<2", "openai>=2.25.0,<4", ] diff --git a/python/packages/openai/tests/openai/test_exceptions.py b/python/packages/openai/tests/openai/test_exceptions.py index b837b1a3d34..880faa2e24e 100644 --- a/python/packages/openai/tests/openai/test_exceptions.py +++ b/python/packages/openai/tests/openai/test_exceptions.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Iterator, Mapping from typing import Any from unittest.mock import MagicMock @@ -10,12 +9,7 @@ from openai import BadRequestError from agent_framework_openai import OpenAIContentFilterException -from agent_framework_openai._exceptions import ( - ContentFilterCodes, - ContentFilterResult, - ContentFilterResultSeverity, - _get_openai_error_details, -) +from agent_framework_openai._exceptions import ContentFilterCodes, ContentFilterResult, ContentFilterResultSeverity @pytest.mark.parametrize( @@ -47,51 +41,3 @@ def test_content_filter_exception_codes(inner_code: str | None, expected_code: s assert exception.content_filter_result == { "self_harm": ContentFilterResult(filtered=True, severity=ContentFilterResultSeverity.MEDIUM), } - - -def test_openai_error_details_preserve_structured_body() -> None: - error = BadRequestError( - "rendered error", - response=MagicMock(status_code=400), - body={ - "error": { - "code": "missing_required_parameter", - "message": "Input is required.", - "type": "invalid_request_error", - "param": "input", - "request_id": "req_test", - } - }, - ) - details = _get_openai_error_details(error) - - assert details is not None - assert details.code == "missing_required_parameter" - assert details.message == "Input is required." - assert details.status_code == 400 - assert details.additional_properties == { - "code": "missing_required_parameter", - "message": "Input is required.", - "type": "invalid_request_error", - "param": "input", - "request_id": "req_test", - } - - -def test_openai_error_details_fail_closed_when_mapping_access_raises() -> None: - class BrokenMapping(Mapping[str, object]): - def __getitem__(self, key: str) -> object: - raise RuntimeError("broken body") - - def __iter__(self) -> Iterator[str]: - return iter(("error",)) - - def __len__(self) -> int: - return 1 - - error = BadRequestError( - "rendered error", - response=MagicMock(status_code=400), - body=BrokenMapping(), - ) - assert _get_openai_error_details(error) is None diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index f26d40e461f..15cadc9d9b9 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -7987,9 +7987,6 @@ async def test_service_response_exception_includes_original_error_details() -> N exception_message = str(exc_info.value) assert "service failed to complete the prompt:" in exception_message assert original_error_message in exception_message - assert exc_info.value.error_details is not None - assert exc_info.value.error_details.code == "rate_limit" - assert exc_info.value.error_details.message == original_error_message async def test_get_response_streaming_with_response_format() -> None: diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index 9820e40fd9f..7d5fc75b077 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -410,9 +410,6 @@ async def test_exception_message_includes_original_error_details() -> None: exception_message = str(exc_info.value) assert "service failed to complete the prompt:" in exception_message assert original_error_message in exception_message - assert exc_info.value.error_details is not None - assert exc_info.value.error_details.code == "invalid_request" - assert exc_info.value.error_details.message == original_error_message def test_chat_response_content_order_text_before_tool_calls( From 89be748b5ee24de10a4610ac401d8689ed6e1e00 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:04:06 -0700 Subject: [PATCH 4/4] fix(python): guard provider error serialization Fail closed to the sanitized terminal error when a provider body contains non-JSON values or cycles. --- .../_responses.py | 1 + .../foundry_hosting/tests/test_responses.py | 27 +++++++++++++++++++ .../_parsing.py | 1 + .../tests/hosting_responses/test_parsing.py | 26 ++++++++++++++++++ 4 files changed, 55 insertions(+) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index 75d84ed9920..9f7b27125e1 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -179,6 +179,7 @@ def _provider_error_from_chat_client(exception: BaseException) -> dict[str, Any] return None if isinstance(status_code, int) and not isinstance(status_code, bool): payload["status_code"] = status_code + json.dumps(payload, allow_nan=False) return payload except Exception: return None diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index 75ef60462ba..14a00eee075 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -178,6 +178,33 @@ def __init__(self) -> None: assert failure_mapping["status_code"] == status +@pytest.mark.parametrize("unsafe_value", [b"not-json", object(), float("nan")]) +def test_failure_event_rejects_non_json_provider_body(unsafe_value: object) -> None: + class ProviderError(Exception): + code = "provider_error" + message = _PRIVATE_ERROR_DETAIL + status_code = 500 + body = {"unsafe": unsafe_value} + + error = ChatClientException("provider wrapper", inner_exception=ProviderError()) + stream = ResponseEventStream(response_id="resp_unsafe_provider_error") + stream.emit_created() + stream.emit_in_progress() + events = list( + ResponsesHostServer._emit_failure( + "resp_unsafe_provider_error", + "unsafe provider failure", + stream, + None, + error, + ) + ) + terminal = cast(dict[str, Any], events[-1]) + response = cast(dict[str, Any], terminal["response"]) + failure = response["error"] + assert failure == {"code": "server_error", "message": "An internal server error occurred."} + + @pytest.fixture(scope="session", autouse=True) def _isolate_agentserver_state_root(tmp_path_factory: pytest.TempPathFactory) -> Generator[None]: """Keep the real local state store, but prevent xdist workers sharing its files.""" diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py index cb527432345..6b51949e3f6 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py @@ -109,6 +109,7 @@ def _provider_error_from_chat_client(exception: Exception) -> dict[str, Any] | N return None if isinstance(status_code, int) and not isinstance(status_code, bool): payload["status_code"] = status_code + json.dumps(payload, allow_nan=False) return payload except Exception: return None diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py index 295a3560e80..5ffa69b82a0 100644 --- a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py +++ b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py @@ -1523,6 +1523,32 @@ async def updates() -> AsyncIterator[AgentResponseUpdate]: assert failure["status_code"] == status assert "partial" in events[-1] + @pytest.mark.parametrize("unsafe_value", [b"not-json", object(), float("nan")]) + async def test_stream_rejects_non_json_provider_body(self, unsafe_value: object) -> None: + class ProviderError(Exception): + code = "provider_error" + message = "private provider details" + status_code = 500 + body = {"unsafe": unsafe_value} + + error = ChatClientException("provider wrapper", inner_exception=ProviderError()) + + async def updates() -> AsyncIterator[AgentResponseUpdate]: + raise error + yield # pragma: no cover + + events = [ + event + async for event in responses_from_streaming_run( + ResponseStream(updates(), finalizer=AgentResponse.from_updates), + response_id="resp_unsafe_provider_error", + ) + ] + response = cast("dict[str, object]", _sse_payload(events[-1])["response"]) + failure = cast("dict[str, object]", response["error"]) + assert failure == {"code": "server_error", "message": "An internal server error occurred."} + assert events[-1].startswith("event: response.failed") + async def test_failed_stream_preserves_partial_marked_refusal(self) -> None: async def updates() -> AsyncIterator[AgentResponseUpdate]: yield AgentResponseUpdate(