From 8e129fd69f08fab9438df24f88db688ab76b531d Mon Sep 17 00:00:00 2001 From: Karth Date: Tue, 25 Aug 2026 00:51:13 -0400 Subject: [PATCH 1/2] Wrap Anthropic and Gemini SDK exceptions in ChatClientException _inner_get_response() in the OpenAI, Mistral, Ollama, and Bedrock chat clients all translate raw provider SDK exceptions into the framework's ChatClientException hierarchy (ChatClientInvalidAuthException for auth failures, ChatClientInvalidRequestException for bad requests, ChatClientException otherwise). Mistral's test suite explicitly asserts this behavior (test_get_response_http_error_wrapped, test_get_response_network_error_wrapped). The Anthropic and Gemini clients call their SDKs directly with no try/except at all, so a raw anthropic.APIError or google.genai.errors.APIError (and subclasses) propagates unwrapped. Code that catches ChatClientException to handle chat-client failures in a provider-agnostic way - the documented purpose of that base class - silently fails to catch failures from these two providers. Wraps both the streaming and non-streaming call sites in both clients, using each SDK's real exception hierarchy (verified against the installed anthropic and google-genai packages, not assumed) to classify auth vs. bad-request vs. other errors the same way Mistral already does. Adds regression tests mirroring Mistral's existing coverage: one parametrized non-streaming test per provider covering all three exception classes, plus a streaming-path test per provider. Full anthropic and gemini unit test suites pass locally (162 and 154 tests respectively, no regressions). --- .../agent_framework_anthropic/_chat_client.py | 38 +++++++-- .../anthropic/tests/test_anthropic_client.py | 81 ++++++++++++++++++- .../agent_framework_gemini/_chat_client.py | 45 +++++++++-- .../gemini/tests/test_gemini_client.py | 49 ++++++++++- 4 files changed, 193 insertions(+), 20 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 3ed4d1b664..269506d215 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -31,8 +31,16 @@ 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 ( + 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.lib.bedrock import AsyncAnthropicBedrock from anthropic.lib.vertex import AsyncAnthropicVertex from anthropic.types.beta import ( @@ -553,17 +561,37 @@ 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 AnthropicAuthenticationError as ex: + raise ChatClientInvalidAuthException( + f"Anthropic authentication failed: {ex}", inner_exception=ex + ) from ex + except AnthropicBadRequestError as ex: + raise ChatClientInvalidRequestException( + f"Invalid Anthropic request: {ex}", inner_exception=ex + ) from ex + except AnthropicAPIError as ex: + raise ChatClientException(f"Anthropic chat request failed: {ex}", inner_exception=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 AnthropicAuthenticationError as ex: + raise ChatClientInvalidAuthException( + f"Anthropic authentication failed: {ex}", inner_exception=ex + ) from ex + except AnthropicBadRequestError as ex: + raise ChatClientInvalidRequestException(f"Invalid Anthropic request: {ex}", inner_exception=ex) from ex + except AnthropicAPIError as ex: + raise ChatClientException(f"Anthropic chat request failed: {ex}", inner_exception=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 e6f8cc36a2..4aff42a983 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,7 +25,15 @@ ) 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 agent_framework_anthropic import AnthropicChatOptions, AnthropicClient, RawAnthropicClient +from agent_framework_anthropic._chat_client import AnthropicSettings +from agent_framework_anthropic._feature_usage import FeatureIndex from anthropic.types.beta import ( BetaMessage, BetaMessageDeltaUsage, @@ -33,10 +43,6 @@ ) from pydantic import BaseModel, Field -from agent_framework_anthropic import AnthropicChatOptions, AnthropicClient, RawAnthropicClient -from agent_framework_anthropic._chat_client import AnthropicSettings -from agent_framework_anthropic._feature_usage import FeatureIndex - # Test constants VALID_PNG_BASE64 = b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" @@ -1802,6 +1808,73 @@ 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", "status_code", "expected_exception"), + [ + ( + anthropic_sdk.AuthenticationError, + 401, + ChatClientInvalidAuthException, + ), + ( + anthropic_sdk.BadRequestError, + 400, + ChatClientInvalidRequestException, + ), + ( + anthropic_sdk.InternalServerError, + 500, + ChatClientException, + ), + ], +) +async def test_inner_get_response_wraps_sdk_errors( + mock_anthropic_client: MagicMock, + sdk_exception: type[anthropic_sdk.APIStatusError], + status_code: int, + 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 = _anthropic_status_error(sdk_exception, status_code, "boom") + + 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, not just the non-streaming path.""" + client = create_test_anthropic_client(mock_anthropic_client) + mock_anthropic_client.beta.messages.create.side_effect = _anthropic_status_error( + anthropic_sdk.AuthenticationError, 401, "invalid api key" + ) + + messages = [Message(role="user", contents=["Hi"])] + chat_options = ChatOptions(max_tokens=10) + + 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 515bab2f3f..70495c7035 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 ( + 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 google.genai.errors import ClientError as GenAIClientError from pydantic import BaseModel from ._feature_usage import FeatureIndex @@ -558,12 +565,21 @@ 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 GenAIClientError as ex: + if ex.code == 401: + raise ChatClientInvalidAuthException( + f"Gemini authentication failed: {ex}", inner_exception=ex + ) from ex + raise ChatClientInvalidRequestException(f"Invalid Gemini request: {ex}", inner_exception=ex) from ex + except GenAIAPIError as ex: + raise ChatClientException(f"Gemini chat request failed: {ex}", inner_exception=ex) from ex return self._build_response_stream(_stream(), response_format=options.get("response_format")) @@ -571,7 +587,20 @@ 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] + try: + raw = await self._genai_client.aio.models.generate_content( + model=model, + contents=contents, + config=config, # type: ignore[arg-type] + ) + except GenAIClientError as ex: + if ex.code == 401: + raise ChatClientInvalidAuthException( + f"Gemini authentication failed: {ex}", inner_exception=ex + ) from ex + raise ChatClientInvalidRequestException(f"Invalid Gemini request: {ex}", inner_exception=ex) from ex + except GenAIAPIError as ex: + raise ChatClientException(f"Gemini chat request failed: {ex}", inner_exception=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 e23ae0a8c6..0e9c8eb949 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -12,13 +12,18 @@ import pytest from agent_framework import Agent, Content, FunctionTool, Message +from agent_framework.exceptions import ( + ChatClientException, + ChatClientInvalidAuthException, + ChatClientInvalidRequestException, +) +from agent_framework_gemini import GeminiChatClient, GeminiChatOptions, RawGeminiChatClient, ThinkingConfig +from agent_framework_gemini._feature_usage import FeatureIndex +from google.genai import errors as genai_errors from google.genai import types from pydantic import BaseModel from typing_extensions import NotRequired, TypedDict -from agent_framework_gemini import GeminiChatClient, GeminiChatOptions, RawGeminiChatClient, ThinkingConfig -from agent_framework_gemini._feature_usage import FeatureIndex - def _has_gemini_integration_credentials() -> bool: """Return whether integration credentials for either Gemini API or Vertex AI appear to be configured.""" @@ -378,6 +383,44 @@ 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(400, {"error": {"message": "bad request"}}), ChatClientInvalidRequestException), + (genai_errors.ServerError(500, {"error": {"message": "server error"}}), ChatClientException), + ], +) +async def test_get_response_wraps_sdk_errors( + sdk_exception: genai_errors.APIError, 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, not just the non-streaming path.""" + client, mock = _make_gemini_client() + mock.aio.models.generate_content_stream = AsyncMock( + side_effect=genai_errors.ClientError(401, {"error": {"message": "invalid api key"}}) + ) + + stream = client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + stream=True, + ) + with pytest.raises(ChatClientInvalidAuthException, match="Gemini"): + async for _ in stream: + 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() From 652c3d0240b61453201abd14bac0690349acf826 Mon Sep 17 00:00:00 2001 From: Karth Date: Fri, 28 Aug 2026 06:13:59 -0400 Subject: [PATCH 2/2] Address review: classify 403 as auth, wrap non-APIError failures, cover mid-stream errors - Map HTTP 403 (Anthropic PermissionDeniedError / Gemini ClientError) to ChatClientInvalidAuthException, matching Mistral's 401/403 handling. - Replace the provider-specific except chain with a catch-all that routes every non-framework exception through a _wrap_*_error helper, so transport failures and credential-refresh errors no longer escape ChatClientException. - Re-raise AgentFrameworkException (e.g. ContentError) untouched. - Extend tests to cover 403, a non-APIError fallback, and a failure raised partway through iterating the stream (generator yields then raises). --- .../agent_framework_anthropic/_chat_client.py | 45 ++++++++------ .../anthropic/tests/test_anthropic_client.py | 60 +++++++++++-------- .../agent_framework_gemini/_chat_client.py | 54 ++++++++++------- .../gemini/tests/test_gemini_client.py | 35 ++++++++--- 4 files changed, 119 insertions(+), 75 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 269506d215..bc7f3190ea 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -32,6 +32,7 @@ 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, @@ -41,6 +42,7 @@ 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 ( @@ -95,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 @@ -566,16 +585,10 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: parsed_chunk = self._process_stream_event(chunk, emitted_usage) if parsed_chunk: yield parsed_chunk - except AnthropicAuthenticationError as ex: - raise ChatClientInvalidAuthException( - f"Anthropic authentication failed: {ex}", inner_exception=ex - ) from ex - except AnthropicBadRequestError as ex: - raise ChatClientInvalidRequestException( - f"Invalid Anthropic request: {ex}", inner_exception=ex - ) from ex - except AnthropicAPIError as ex: - raise ChatClientException(f"Anthropic chat request failed: {ex}", inner_exception=ex) from ex + 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")) @@ -584,14 +597,10 @@ async def _get_response() -> ChatResponse: mark_feature_used(FeatureIndex.ANTHROPIC) try: message = await self.anthropic_client.beta.messages.create(**run_options, stream=False) - except AnthropicAuthenticationError as ex: - raise ChatClientInvalidAuthException( - f"Anthropic authentication failed: {ex}", inner_exception=ex - ) from ex - except AnthropicBadRequestError as ex: - raise ChatClientInvalidRequestException(f"Invalid Anthropic request: {ex}", inner_exception=ex) from ex - except AnthropicAPIError as ex: - raise ChatClientException(f"Anthropic chat request failed: {ex}", inner_exception=ex) from ex + 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 4aff42a983..b1961eb8d0 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -31,9 +31,6 @@ ChatClientInvalidRequestException, ) from agent_framework.observability import ChatTelemetryLayer -from agent_framework_anthropic import AnthropicChatOptions, AnthropicClient, RawAnthropicClient -from agent_framework_anthropic._chat_client import AnthropicSettings -from agent_framework_anthropic._feature_usage import FeatureIndex from anthropic.types.beta import ( BetaMessage, BetaMessageDeltaUsage, @@ -43,6 +40,10 @@ ) from pydantic import BaseModel, Field +from agent_framework_anthropic import AnthropicChatOptions, AnthropicClient, RawAnthropicClient +from agent_framework_anthropic._chat_client import AnthropicSettings +from agent_framework_anthropic._feature_usage import FeatureIndex + # Test constants VALID_PNG_BASE64 = b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" @@ -1817,36 +1818,27 @@ def _anthropic_status_error( @pytest.mark.parametrize( - ("sdk_exception", "status_code", "expected_exception"), + ("sdk_exception", "expected_exception"), [ - ( - anthropic_sdk.AuthenticationError, - 401, - ChatClientInvalidAuthException, - ), - ( - anthropic_sdk.BadRequestError, - 400, - ChatClientInvalidRequestException, - ), - ( - anthropic_sdk.InternalServerError, - 500, - ChatClientException, - ), + (_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: type[anthropic_sdk.APIStatusError], - status_code: int, + 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 = _anthropic_status_error(sdk_exception, status_code, "boom") + mock_anthropic_client.beta.messages.create.side_effect = sdk_exception messages = [Message(role="user", contents=["Hi"])] chat_options = ChatOptions(max_tokens=10) @@ -1858,16 +1850,32 @@ async def test_inner_get_response_wraps_sdk_errors( 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, not just the non-streaming path.""" + """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 - messages = [Message(role="user", contents=["Hi"])] - chat_options = ChatOptions(max_tokens=10) + # 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 diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 70495c7035..cb9f231915 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -33,6 +33,7 @@ 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 ( + AgentFrameworkException, ChatClientException, ChatClientInvalidAuthException, ChatClientInvalidRequestException, @@ -43,7 +44,6 @@ from google.auth.credentials import Credentials from google.genai import types from google.genai.errors import APIError as GenAIAPIError -from google.genai.errors import ClientError as GenAIClientError from pydantic import BaseModel from ._feature_usage import FeatureIndex @@ -77,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 @@ -572,14 +590,10 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: config=config, ): yield self._process_chunk(chunk) - except GenAIClientError as ex: - if ex.code == 401: - raise ChatClientInvalidAuthException( - f"Gemini authentication failed: {ex}", inner_exception=ex - ) from ex - raise ChatClientInvalidRequestException(f"Invalid Gemini request: {ex}", inner_exception=ex) from ex - except GenAIAPIError as ex: - raise ChatClientException(f"Gemini chat request failed: {ex}", inner_exception=ex) from ex + 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")) @@ -587,20 +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) + generate_content = cast( + Callable[..., Awaitable[types.GenerateContentResponse]], + cast(Any, self._genai_client.aio.models).generate_content, + ) try: - raw = await self._genai_client.aio.models.generate_content( - model=model, - contents=contents, - config=config, # type: ignore[arg-type] - ) - except GenAIClientError as ex: - if ex.code == 401: - raise ChatClientInvalidAuthException( - f"Gemini authentication failed: {ex}", inner_exception=ex - ) from ex - raise ChatClientInvalidRequestException(f"Invalid Gemini request: {ex}", inner_exception=ex) from ex - except GenAIAPIError as ex: - raise ChatClientException(f"Gemini chat request failed: {ex}", inner_exception=ex) from ex + 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 0e9c8eb949..3a7aed6066 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -17,13 +17,14 @@ ChatClientInvalidAuthException, ChatClientInvalidRequestException, ) -from agent_framework_gemini import GeminiChatClient, GeminiChatOptions, RawGeminiChatClient, ThinkingConfig -from agent_framework_gemini._feature_usage import FeatureIndex from google.genai import errors as genai_errors from google.genai import types from pydantic import BaseModel from typing_extensions import NotRequired, TypedDict +from agent_framework_gemini import GeminiChatClient, GeminiChatOptions, RawGeminiChatClient, ThinkingConfig +from agent_framework_gemini._feature_usage import FeatureIndex + def _has_gemini_integration_credentials() -> bool: """Return whether integration credentials for either Gemini API or Vertex AI appear to be configured.""" @@ -387,12 +388,16 @@ async def test_get_response_returns_text() -> None: ("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: genai_errors.APIError, expected_exception: type[Exception] + 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 @@ -406,18 +411,30 @@ async def test_get_response_wraps_sdk_errors( 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, not just the non-streaming path.""" + 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 - stream = client.get_response( - messages=[Message(role="user", contents=[Content.from_text("Hi")])], - stream=True, - ) + # 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 stream: + async for _ in client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], stream=True + ): pass