diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 3ed4d1b664c..bc7f3190ea0 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -31,8 +31,18 @@ from agent_framework._telemetry import get_user_agent, mark_feature_used from agent_framework._tools import SHELL_TOOL_KIND_VALUE, normalize_tools from agent_framework._types import _get_data_bytes_as_str # type: ignore +from agent_framework.exceptions import ( + AgentFrameworkException, + ChatClientException, + ChatClientInvalidAuthException, + ChatClientInvalidRequestException, +) from agent_framework.observability import ChatTelemetryLayer +from anthropic import APIError as AnthropicAPIError from anthropic import AsyncAnthropic, AsyncAnthropicFoundry +from anthropic import AuthenticationError as AnthropicAuthenticationError +from anthropic import BadRequestError as AnthropicBadRequestError +from anthropic import PermissionDeniedError as AnthropicPermissionDeniedError from anthropic.lib.bedrock import AsyncAnthropicBedrock from anthropic.lib.vertex import AsyncAnthropicVertex from anthropic.types.beta import ( @@ -87,6 +97,23 @@ AnthropicAsyncClient = AsyncAnthropic | AsyncAnthropicBedrock | AsyncAnthropicFoundry | AsyncAnthropicVertex +def _wrap_anthropic_error(ex: Exception) -> ChatClientException: + """Translate a raw anthropic-sdk failure into the framework's ChatClientException hierarchy. + + ``APIError`` instances are classified by HTTP status (401/403 -> auth, other 4xx -> + invalid request), matching the Mistral client. Anything else - connection errors, + timeouts, unexpected SDK exceptions - is still wrapped as a generic ``ChatClientException`` + so callers catching that base type never see a raw provider exception leak through. + """ + if isinstance(ex, AnthropicAPIError): + status = getattr(ex, "status_code", None) + if isinstance(ex, (AnthropicAuthenticationError, AnthropicPermissionDeniedError)) or status in (401, 403): + return ChatClientInvalidAuthException(f"Anthropic authentication failed: {ex}", inner_exception=ex) + if isinstance(ex, AnthropicBadRequestError) or (isinstance(status, int) and 400 <= status < 500): + return ChatClientInvalidRequestException(f"Invalid Anthropic request: {ex}", inner_exception=ex) + return ChatClientException(f"Anthropic chat request failed: {ex}", inner_exception=ex) + + # region Anthropic Chat Options TypedDict @@ -553,17 +580,27 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: # accumulator to _process_stream_event to emit increments instead. emitted_usage: dict[str, int] = {} mark_feature_used(FeatureIndex.ANTHROPIC) - async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True): - parsed_chunk = self._process_stream_event(chunk, emitted_usage) - if parsed_chunk: - yield parsed_chunk + try: + async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True): + parsed_chunk = self._process_stream_event(chunk, emitted_usage) + if parsed_chunk: + yield parsed_chunk + except AgentFrameworkException: + raise + except Exception as ex: + raise _wrap_anthropic_error(ex) from ex return self._build_response_stream(_stream(), response_format=options.get("response_format")) # Non-streaming mode async def _get_response() -> ChatResponse: mark_feature_used(FeatureIndex.ANTHROPIC) - message = await self.anthropic_client.beta.messages.create(**run_options, stream=False) + try: + message = await self.anthropic_client.beta.messages.create(**run_options, stream=False) + except AgentFrameworkException: + raise + except Exception as ex: + raise _wrap_anthropic_error(ex) from ex return self._process_message(message, options) return _get_response() diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index e6f8cc36a21..b1961eb8d03 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -5,6 +5,8 @@ from typing import Annotated, Any, cast from unittest.mock import MagicMock, patch +import anthropic as anthropic_sdk +import httpx import pytest from agent_framework import ( Agent, @@ -23,6 +25,11 @@ ) from agent_framework._settings import load_settings from agent_framework._tools import SHELL_TOOL_KIND_VALUE +from agent_framework.exceptions import ( + ChatClientException, + ChatClientInvalidAuthException, + ChatClientInvalidRequestException, +) from agent_framework.observability import ChatTelemetryLayer from anthropic.types.beta import ( BetaMessage, @@ -1802,6 +1809,80 @@ async def mock_stream(): assert mock_anthropic_client.beta.messages.create.call_args.kwargs["stream"] is True +def _anthropic_status_error( + error_cls: type[anthropic_sdk.APIStatusError], status_code: int, message: str +) -> anthropic_sdk.APIStatusError: + request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + response = httpx.Response(status_code, request=request, json={"error": {"message": message}}) + return error_cls(message, response=response, body={"error": {"message": message}}) + + +@pytest.mark.parametrize( + ("sdk_exception", "expected_exception"), + [ + (_anthropic_status_error(anthropic_sdk.AuthenticationError, 401, "boom"), ChatClientInvalidAuthException), + (_anthropic_status_error(anthropic_sdk.PermissionDeniedError, 403, "boom"), ChatClientInvalidAuthException), + (_anthropic_status_error(anthropic_sdk.BadRequestError, 400, "boom"), ChatClientInvalidRequestException), + (_anthropic_status_error(anthropic_sdk.InternalServerError, 500, "boom"), ChatClientException), + # Not an anthropic APIError at all (connection reset, timeout, unexpected SDK bug): + # must still be wrapped so ``except ChatClientException`` callers never see it raw. + (RuntimeError("connection reset"), ChatClientException), + ], +) +async def test_inner_get_response_wraps_sdk_errors( + mock_anthropic_client: MagicMock, + sdk_exception: Exception, + expected_exception: type[Exception], +) -> None: + """Non-streaming _inner_get_response must translate raw Anthropic SDK errors into + the framework's ChatClientException hierarchy, matching every other provider + (OpenAI, Mistral, Ollama, Bedrock).""" + client = create_test_anthropic_client(mock_anthropic_client) + mock_anthropic_client.beta.messages.create.side_effect = sdk_exception + + messages = [Message(role="user", contents=["Hi"])] + chat_options = ChatOptions(max_tokens=10) + + with pytest.raises(expected_exception, match="Anthropic"): + await client._inner_get_response( # type: ignore[attr-defined] + messages=messages, options=chat_options + ) + + +async def test_inner_get_response_streaming_wraps_sdk_errors(mock_anthropic_client: MagicMock) -> None: + """Streaming _inner_get_response must translate raw Anthropic SDK errors into the + framework's ChatClientException hierarchy too, both when the create() call fails and + when the failure happens partway through iterating the stream.""" + client = create_test_anthropic_client(mock_anthropic_client) + messages = [Message(role="user", contents=["Hi"])] + chat_options = ChatOptions(max_tokens=10) + + # 1. Failure raised by the create() call itself. + mock_anthropic_client.beta.messages.create.side_effect = _anthropic_status_error( + anthropic_sdk.AuthenticationError, 401, "invalid api key" + ) + with pytest.raises(ChatClientInvalidAuthException, match="Anthropic"): + async for _ in client._inner_get_response( # type: ignore[attr-defined] # ty: ignore[not-iterable] + messages=messages, options=chat_options, stream=True + ): + pass + + # 2. Failure raised mid-stream, after at least one event has been yielded. + async def _raise_after_first_event() -> Any: + event = MagicMock() + event.type = "message_stop" + yield event + raise _anthropic_status_error(anthropic_sdk.PermissionDeniedError, 403, "permission denied") + + mock_anthropic_client.beta.messages.create.side_effect = None + mock_anthropic_client.beta.messages.create.return_value = _raise_after_first_event() + with pytest.raises(ChatClientInvalidAuthException, match="Anthropic"): + async for _ in client._inner_get_response( # type: ignore[attr-defined] # ty: ignore[not-iterable] + messages=messages, options=chat_options, stream=True + ): + pass + + def test_process_stream_event_message_start_sets_assistant_role(mock_anthropic_client: MagicMock) -> None: """Test that message_start streaming event sets role='assistant'. diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 515bab2f3f4..cb9f2319150 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -32,11 +32,18 @@ from agent_framework._settings import SecretString, load_settings from agent_framework._telemetry import get_user_agent, mark_feature_used from agent_framework._types import _get_data_bytes # type: ignore[reportPrivateUsage] -from agent_framework.exceptions import ContentError +from agent_framework.exceptions import ( + AgentFrameworkException, + ChatClientException, + ChatClientInvalidAuthException, + ChatClientInvalidRequestException, + ContentError, +) from agent_framework.observability import ChatTelemetryLayer from google import genai from google.auth.credentials import Credentials from google.genai import types +from google.genai.errors import APIError as GenAIAPIError from pydantic import BaseModel from ._feature_usage import FeatureIndex @@ -70,6 +77,24 @@ ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) +def _wrap_gemini_error(ex: Exception) -> ChatClientException: + """Translate a raw google-genai failure into the framework's ChatClientException hierarchy. + + google-genai ``APIError`` instances are classified by HTTP status (401/403 -> auth, other + 4xx -> invalid request), matching the Mistral client. Anything else - transport errors, + Vertex credential-refresh failures, unexpected SDK exceptions - is still wrapped as a + generic ``ChatClientException`` so callers catching that base type never see a raw + provider exception leak through. + """ + if isinstance(ex, GenAIAPIError): + code = getattr(ex, "code", None) + if code in (401, 403): + return ChatClientInvalidAuthException(f"Gemini authentication failed: {ex}", inner_exception=ex) + if isinstance(code, int) and 400 <= code < 500: + return ChatClientInvalidRequestException(f"Invalid Gemini request: {ex}", inner_exception=ex) + return ChatClientException(f"Gemini chat request failed: {ex}", inner_exception=ex) + + # region Options & Settings @@ -558,12 +583,17 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: Callable[..., Awaitable[AsyncIterable[types.GenerateContentResponse]]], cast(Any, self._genai_client.aio.models).generate_content_stream, ) - async for chunk in await generate_content_stream( - model=model, - contents=contents, - config=config, - ): - yield self._process_chunk(chunk) + try: + async for chunk in await generate_content_stream( + model=model, + contents=contents, + config=config, + ): + yield self._process_chunk(chunk) + except AgentFrameworkException: + raise + except Exception as ex: + raise _wrap_gemini_error(ex) from ex return self._build_response_stream(_stream(), response_format=options.get("response_format")) @@ -571,7 +601,16 @@ async def _get_response() -> ChatResponse: validated = await self._validate_options(options) model, contents, config = self._prepare_request(messages, validated) mark_feature_used(FeatureIndex.GEMINI) - raw = await self._genai_client.aio.models.generate_content(model=model, contents=contents, config=config) # type: ignore[arg-type] + generate_content = cast( + Callable[..., Awaitable[types.GenerateContentResponse]], + cast(Any, self._genai_client.aio.models).generate_content, + ) + try: + raw = await generate_content(model=model, contents=contents, config=config) + except AgentFrameworkException: + raise + except Exception as ex: + raise _wrap_gemini_error(ex) from ex return self._process_generate_response(raw, response_format=validated.get("response_format")) return _get_response() diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index e23ae0a8c6e..3a7aed60662 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -12,6 +12,12 @@ import pytest from agent_framework import Agent, Content, FunctionTool, Message +from agent_framework.exceptions import ( + ChatClientException, + ChatClientInvalidAuthException, + ChatClientInvalidRequestException, +) +from google.genai import errors as genai_errors from google.genai import types from pydantic import BaseModel from typing_extensions import NotRequired, TypedDict @@ -378,6 +384,60 @@ async def test_get_response_returns_text() -> None: assert response.messages[0].text == "Hello!" +@pytest.mark.parametrize( + ("sdk_exception", "expected_exception"), + [ + (genai_errors.ClientError(401, {"error": {"message": "invalid api key"}}), ChatClientInvalidAuthException), + (genai_errors.ClientError(403, {"error": {"message": "permission denied"}}), ChatClientInvalidAuthException), + (genai_errors.ClientError(400, {"error": {"message": "bad request"}}), ChatClientInvalidRequestException), + (genai_errors.ServerError(500, {"error": {"message": "server error"}}), ChatClientException), + # Not a google-genai APIError at all (transport failure, credential refresh, ...): + # must still be wrapped so ``except ChatClientException`` callers never see it raw. + (RuntimeError("connection reset"), ChatClientException), + ], +) +async def test_get_response_wraps_sdk_errors( + sdk_exception: Exception, expected_exception: type[Exception] +) -> None: + """Non-streaming get_response must translate raw google-genai SDK errors into the + framework's ChatClientException hierarchy, matching every other provider + (OpenAI, Anthropic, Mistral, Ollama, Bedrock).""" + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(side_effect=sdk_exception) + + with pytest.raises(expected_exception, match="Gemini"): + await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Hi")])]) + + +async def test_get_response_streaming_wraps_sdk_errors() -> None: + """Streaming get_response must translate raw google-genai SDK errors into the + framework's ChatClientException hierarchy too, both when the call itself fails + and when the failure happens partway through iterating the stream.""" + # 1. Failure raised by the generate_content_stream call itself. + client, mock = _make_gemini_client() + mock.aio.models.generate_content_stream = AsyncMock( + side_effect=genai_errors.ClientError(401, {"error": {"message": "invalid api key"}}) + ) + with pytest.raises(ChatClientInvalidAuthException, match="Gemini"): + async for _ in client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], stream=True + ): + pass + + # 2. Failure raised mid-stream, after at least one chunk has been yielded. + async def _raise_after_first_chunk(**_: Any): + yield _make_response([_make_part(text="partial")]) + raise genai_errors.ClientError(403, {"error": {"message": "permission denied"}}) + + client, mock = _make_gemini_client() + mock.aio.models.generate_content_stream = AsyncMock(return_value=_raise_after_first_chunk()) + with pytest.raises(ChatClientInvalidAuthException, match="Gemini"): + async for _ in client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], stream=True + ): + pass + + async def test_get_response_model_from_response() -> None: """Populates ChatResponse.model from the model_version field in the API response.""" client, mock = _make_gemini_client()