Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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()
Expand Down
81 changes: 81 additions & 0 deletions python/packages/anthropic/tests/test_anthropic_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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'.

Expand Down
55 changes: 47 additions & 8 deletions python/packages/gemini/agent_framework_gemini/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -558,20 +583,34 @@ 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"))

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()
Expand Down
60 changes: 60 additions & 0 deletions python/packages/gemini/tests/test_gemini_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading