Files
wehub-resource-sync 9201ef759e
CI / lint (push) Waiting to run
CI / mypy (push) Waiting to run
CI / docs (push) Waiting to run
CI / test on 3.10 (standard) (push) Waiting to run
CI / test on 3.11 (standard) (push) Waiting to run
CI / test on 3.12 (standard) (push) Waiting to run
CI / test on 3.10 (all-extras) (push) Waiting to run
CI / test on 3.11 (all-extras) (push) Waiting to run
CI / test on 3.12 (all-extras) (push) Waiting to run
CI / test on 3.13 (all-extras) (push) Waiting to run
CI / test on 3.14 (pydantic-evals) (push) Waiting to run
Harness Compat / harness compat (push) Waiting to run
CI / test on 3.13 (standard) (push) Waiting to run
CI / test on 3.14 (standard) (push) Waiting to run
CI / test on 3.14 (all-extras) (push) Waiting to run
CI / test on 3.10 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.11 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.12 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.13 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.14 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.10 (pydantic-evals) (push) Waiting to run
CI / test on 3.11 (pydantic-evals) (push) Waiting to run
CI / test on 3.12 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (pydantic-evals) (push) Waiting to run
CI / test on 3.10 (lowest-versions) (push) Waiting to run
CI / test on 3.11 (lowest-versions) (push) Waiting to run
CI / test on 3.12 (lowest-versions) (push) Waiting to run
CI / test on 3.13 (lowest-versions) (push) Waiting to run
CI / test on 3.14 (lowest-versions) (push) Waiting to run
CI / test examples on 3.11 (push) Waiting to run
CI / test examples on 3.12 (push) Waiting to run
CI / test examples on 3.13 (push) Waiting to run
CI / test examples on 3.14 (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / check (push) Blocked by required conditions
CI / deploy-docs (push) Blocked by required conditions
CI / deploy-docs-preview (push) Blocked by required conditions
CI / build release artifacts (push) Blocked by required conditions
CI / publish to PyPI (push) Blocked by required conditions
CI / Send tweet (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:27:52 +08:00

968 lines
36 KiB
Python

"""Tests for streaming error wrapping across all providers.
Mid-stream errors from provider SDKs should be wrapped in ModelHTTPError/ModelAPIError
to enable FallbackModel and consistent error handling. See #4729.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import httpx
import pytest
from pydantic_ai import Agent
from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError
from pydantic_ai.models.fallback import FallbackModel
from .conftest import try_import
with try_import() as anthropic_imports:
from anthropic import APIConnectionError as AnthropicConnectionError, APIStatusError as AnthropicStatusError
from anthropic.types.beta import BetaMessage, BetaRawMessageStartEvent, BetaUsage
from pydantic_ai.models.anthropic import AnthropicModel
from pydantic_ai.providers.anthropic import AnthropicProvider
from .models.test_anthropic import MockAnthropic
with try_import() as openai_imports:
from openai import APIConnectionError as OpenAIConnectionError, APIStatusError as OpenAIStatusError
from openai.types import responses
from openai.types.chat import ChatCompletionChunk
from openai.types.chat.chat_completion_chunk import Choice, ChoiceDelta
from pydantic_ai.models.openai import OpenAIChatModel, OpenAIResponsesModel
from pydantic_ai.providers.openai import OpenAIProvider
from .models.mock_openai import MockOpenAI, MockOpenAIResponses, response_message
with try_import() as groq_imports:
from groq import APIConnectionError as GroqConnectionError, APIStatusError as GroqStatusError
from groq.types.chat import ChatCompletionChunk as GroqChunk
from groq.types.chat.chat_completion_chunk import Choice as GroqChoice, ChoiceDelta as GroqChoiceDelta
from pydantic_ai.models.groq import GroqModel
from pydantic_ai.providers.groq import GroqProvider
from .models.test_groq import MockGroq
with try_import() as bedrock_imports:
from botocore.exceptions import ClientError
from pydantic_ai.models.bedrock import BedrockConverseModel
from pydantic_ai.profiles import DEFAULT_PROFILE
from pydantic_ai.providers import Provider
class _StubBedrockClient:
def __init__(self, error: ClientError):
self._error = error
self.meta = SimpleNamespace(endpoint_url='https://bedrock.stub')
def converse(self, **_: Any) -> None: # pragma: lax no cover
raise self._error
def converse_stream(self, **_: Any) -> None:
raise self._error
class _StubBedrockProvider(Provider[Any]):
def __init__(self, client: Any):
self._client = client
@property
def name(self) -> str:
return 'bedrock-stub'
@property
def base_url(self) -> str: # pragma: lax no cover
return 'https://bedrock.stub'
@property
def client(self) -> Any:
return self._client
@staticmethod
def model_profile(model_name: str):
return DEFAULT_PROFILE
def _bedrock_model_with_error(error: ClientError) -> BedrockConverseModel:
return BedrockConverseModel(
'us.amazon.nova-micro-v1:0',
provider=_StubBedrockProvider(_StubBedrockClient(error)),
)
class _StubBedrockStreamClient:
"""Bedrock client that returns a stream yielding one event then raising."""
def __init__(self, error: ClientError):
self._error = error
self.meta = SimpleNamespace(endpoint_url='https://bedrock.stub')
def converse_stream(self, **_: Any) -> dict[str, Any]:
def _stream():
yield {'messageStart': {'role': 'assistant'}}
raise self._error
return {'stream': _stream(), 'ResponseMetadata': {'RequestId': 'stub'}}
def _bedrock_model_with_midstream_error(error: ClientError) -> BedrockConverseModel:
return BedrockConverseModel(
'us.amazon.nova-micro-v1:0',
provider=_StubBedrockProvider(_StubBedrockStreamClient(error)),
)
with try_import() as huggingface_imports:
from huggingface_hub import (
ChatCompletionStreamOutput,
ChatCompletionStreamOutputChoice,
ChatCompletionStreamOutputDelta,
)
from huggingface_hub.errors import HfHubHTTPError
from pydantic_ai.models.huggingface import HuggingFaceModel
from pydantic_ai.providers.huggingface import HuggingFaceProvider
from .models.test_huggingface import MockHuggingFace
with try_import() as mistral_imports:
from mistralai.client.errors import SDKError
from mistralai.client.models import (
CompletionChunk as MistralCompletionChunk,
CompletionEvent as MistralCompletionEvent,
CompletionResponseStreamChoice as MistralCompletionResponseStreamChoice,
DeltaMessage as MistralDeltaMessage,
UsageInfo as MistralUsageInfo,
)
from pydantic_ai.models.mistral import MistralModel
from pydantic_ai.providers.mistral import MistralProvider
from .models.test_mistral import MockMistralAI
with try_import() as xai_imports:
import grpc
from pydantic_ai.models.xai import XaiModel
from pydantic_ai.providers.xai import XaiProvider
from .models.mock_xai import MockXai, get_grok_text_chunk
class _StubRpcError(grpc.RpcError):
"""Stub gRPC error with configurable code and details."""
def __init__(self, code: grpc.StatusCode, details: str):
self._code = code
self._details = details
def code(self) -> grpc.StatusCode:
return self._code
def details(self) -> str:
return self._details
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _httpx_response(status_code: int, url: str = 'https://test.example.com') -> httpx.Response:
return httpx.Response(status_code, request=httpx.Request('POST', url))
def _anthropic_start_event() -> BetaRawMessageStartEvent:
return BetaRawMessageStartEvent(
type='message_start',
message=BetaMessage(
id='msg_1',
content=[],
model='claude-haiku-4-5',
role='assistant',
stop_reason=None,
type='message',
usage=BetaUsage(input_tokens=1, output_tokens=0),
),
)
def _openai_chunk() -> ChatCompletionChunk:
return ChatCompletionChunk(
id='chatcmpl-1',
choices=[Choice(delta=ChoiceDelta(content='hello'), index=0, finish_reason=None)],
created=1234567890,
model='gpt-4o',
object='chat.completion.chunk',
)
def _groq_chunk() -> GroqChunk:
return GroqChunk(
id='chatcmpl-1',
choices=[GroqChoice(delta=GroqChoiceDelta(content='hello', role='assistant'), index=0, finish_reason=None)],
created=1234567890,
model='llama-3.3-70b-versatile',
object='chat.completion.chunk',
x_groq=None,
)
# ---------------------------------------------------------------------------
# Anthropic Tests
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not anthropic_imports(), reason='anthropic not installed')
async def test_anthropic_midstream_status_error(allow_model_requests: None):
"""APIStatusError during stream iteration is wrapped as ModelHTTPError."""
error = AnthropicStatusError(
message='Overloaded',
response=_httpx_response(529),
body={'type': 'error', 'error': {'type': 'overloaded_error'}},
)
stream = [_anthropic_start_event(), error]
mock_client = MockAnthropic.create_stream_mock(stream)
m = AnthropicModel('claude-haiku-4-5', provider=AnthropicProvider(anthropic_client=mock_client))
agent = Agent(m)
with pytest.raises(ModelHTTPError) as exc_info:
async with agent.run_stream('hello') as result:
async for _ in result.stream_text():
pass
assert exc_info.value.status_code == 529
assert exc_info.value.model_name == 'claude-haiku-4-5'
@pytest.mark.skipif(not anthropic_imports(), reason='anthropic not installed')
async def test_anthropic_midstream_connection_error(allow_model_requests: None):
"""APIConnectionError during stream iteration is wrapped as ModelAPIError."""
error = AnthropicConnectionError(request=httpx.Request('POST', 'https://api.anthropic.com'))
stream = [_anthropic_start_event(), error]
mock_client = MockAnthropic.create_stream_mock(stream)
m = AnthropicModel('claude-haiku-4-5', provider=AnthropicProvider(anthropic_client=mock_client))
agent = Agent(m)
with pytest.raises(ModelAPIError) as exc_info:
async with agent.run_stream('hello') as result:
async for _ in result.stream_text():
pass
assert exc_info.value.model_name == 'claude-haiku-4-5'
@pytest.mark.skipif(not anthropic_imports(), reason='anthropic not installed')
async def test_anthropic_peek_error(allow_model_requests: None):
"""APIStatusError during peek is wrapped as ModelHTTPError."""
error = AnthropicStatusError(
message='Rate limited',
response=_httpx_response(429),
body={'type': 'error', 'error': {'type': 'rate_limit_error'}},
)
stream = [error]
mock_client = MockAnthropic.create_stream_mock(stream)
m = AnthropicModel('claude-haiku-4-5', provider=AnthropicProvider(anthropic_client=mock_client))
agent = Agent(m)
with pytest.raises(ModelHTTPError) as exc_info:
async with agent.run_stream('hello'):
pass
assert exc_info.value.status_code == 429
@pytest.mark.skipif(not anthropic_imports(), reason='anthropic not installed')
@pytest.mark.parametrize(
'error_factory,expected_exc',
[
pytest.param(
lambda: AnthropicStatusError(message='SSE error', response=_httpx_response(200), body={'type': 'error'}),
ModelAPIError,
id='status_lt_400',
),
pytest.param(
lambda: AnthropicConnectionError(request=httpx.Request('POST', 'https://api.anthropic.com')),
ModelAPIError,
id='connection',
),
],
)
async def test_anthropic_peek_non_http_error(
allow_model_requests: None, error_factory: Any, expected_exc: type[Exception]
):
"""APIStatusError with status<400 or APIConnectionError during peek is wrapped as ModelAPIError."""
stream = [error_factory()]
mock_client = MockAnthropic.create_stream_mock(stream)
m = AnthropicModel('claude-haiku-4-5', provider=AnthropicProvider(anthropic_client=mock_client))
agent = Agent(m)
with pytest.raises(expected_exc):
async with agent.run_stream('hello'):
pass
@pytest.mark.skipif(not anthropic_imports(), reason='anthropic not installed')
async def test_anthropic_midstream_sse_error_status_200(allow_model_requests: None):
"""Anthropic SSE error event arrives as APIStatusError with status_code=200 and is wrapped as ModelAPIError.
This is the specific bug from #4729: mid-stream overloaded_error comes as HTTP 200 + SSE error event.
"""
error = AnthropicStatusError(
message='Overloaded',
response=_httpx_response(200),
body={'type': 'error', 'error': {'type': 'overloaded_error'}},
)
stream = [_anthropic_start_event(), error]
mock_client = MockAnthropic.create_stream_mock(stream)
m = AnthropicModel('claude-haiku-4-5', provider=AnthropicProvider(anthropic_client=mock_client))
agent = Agent(m)
with pytest.raises(ModelAPIError) as exc_info:
async with agent.run_stream('hello') as result:
async for _ in result.stream_text():
pass
assert exc_info.value.model_name == 'claude-haiku-4-5'
assert 'Overloaded' in exc_info.value.message
# ---------------------------------------------------------------------------
# OpenAI Tests
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not openai_imports(), reason='openai not installed')
async def test_openai_midstream_status_error(allow_model_requests: None):
"""APIStatusError during stream iteration is wrapped as ModelHTTPError."""
error = OpenAIStatusError(
message='Server error',
response=_httpx_response(500),
body={'error': {'message': 'Internal server error'}},
)
stream = [_openai_chunk(), error]
mock_client = MockOpenAI.create_mock_stream(stream)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
with pytest.raises(ModelHTTPError) as exc_info:
async with agent.run_stream('hello') as result:
async for _ in result.stream_text():
pass
assert exc_info.value.status_code == 500
assert exc_info.value.model_name == 'gpt-4o'
@pytest.mark.skipif(not openai_imports(), reason='openai not installed')
async def test_openai_midstream_connection_error(allow_model_requests: None):
"""APIConnectionError during stream iteration is wrapped as ModelAPIError."""
error = OpenAIConnectionError(request=httpx.Request('POST', 'https://api.openai.com'))
stream = [_openai_chunk(), error]
mock_client = MockOpenAI.create_mock_stream(stream)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
with pytest.raises(ModelAPIError) as exc_info:
async with agent.run_stream('hello') as result:
async for _ in result.stream_text():
pass
assert exc_info.value.model_name == 'gpt-4o'
@pytest.mark.skipif(not openai_imports(), reason='openai not installed')
async def test_openai_peek_error(allow_model_requests: None):
"""APIStatusError during peek is wrapped as ModelHTTPError."""
error = OpenAIStatusError(
message='Rate limited',
response=_httpx_response(429),
body={'error': {'message': 'Rate limit exceeded'}},
)
stream = [error]
mock_client = MockOpenAI.create_mock_stream(stream)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
with pytest.raises(ModelHTTPError) as exc_info:
async with agent.run_stream('hello'):
pass
assert exc_info.value.status_code == 429
@pytest.mark.skipif(not openai_imports(), reason='openai not installed')
@pytest.mark.parametrize(
'error_factory,expected_exc',
[
pytest.param(
lambda: OpenAIStatusError(message='SSE error', response=_httpx_response(200), body={}),
ModelAPIError,
id='status_lt_400',
),
pytest.param(
lambda: OpenAIConnectionError(request=httpx.Request('POST', 'https://api.openai.com')),
ModelAPIError,
id='connection',
),
],
)
async def test_openai_peek_non_http_error(
allow_model_requests: None, error_factory: Any, expected_exc: type[Exception]
):
"""APIStatusError with status<400 or APIConnectionError during peek is wrapped as ModelAPIError."""
stream = [error_factory()]
mock_client = MockOpenAI.create_mock_stream(stream)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
with pytest.raises(expected_exc):
async with agent.run_stream('hello'):
pass
@pytest.mark.skipif(not openai_imports(), reason='openai not installed')
async def test_openai_midstream_non_http_error(allow_model_requests: None):
"""APIStatusError with status<400 during stream iteration is wrapped as ModelAPIError."""
error = OpenAIStatusError(message='SSE error', response=_httpx_response(200), body={})
stream = [_openai_chunk(), error]
mock_client = MockOpenAI.create_mock_stream(stream)
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
with pytest.raises(ModelAPIError):
async with agent.run_stream('hello') as result:
async for _ in result.stream_text():
pass
# ---------------------------------------------------------------------------
# OpenAI Responses Tests
# ---------------------------------------------------------------------------
def _openai_responses_created_event() -> responses.ResponseCreatedEvent:
resp = response_message([])
return responses.ResponseCreatedEvent(response=resp, type='response.created', sequence_number=0)
@pytest.mark.skipif(not openai_imports(), reason='openai not installed')
@pytest.mark.parametrize(
'error_factory,expected_exc,expected_status',
[
pytest.param(
lambda: OpenAIStatusError(message='Server error', response=_httpx_response(500), body={}),
ModelHTTPError,
500,
id='http',
),
pytest.param(
lambda: OpenAIStatusError(message='SSE error', response=_httpx_response(200), body={}),
ModelAPIError,
None,
id='status_lt_400',
),
pytest.param(
lambda: OpenAIConnectionError(request=httpx.Request('POST', 'https://api.openai.com')),
ModelAPIError,
None,
id='connection',
),
],
)
async def test_openai_responses_peek_error(
allow_model_requests: None, error_factory: Any, expected_exc: type[Exception], expected_status: int | None
):
"""Errors during peek on OpenAI Responses model are wrapped correctly."""
stream = [error_factory()]
mock_client = MockOpenAIResponses.create_mock_stream(stream)
m = OpenAIResponsesModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
with pytest.raises(expected_exc) as exc_info:
async with agent.run_stream('hello'):
pass
if expected_status is not None:
assert isinstance(exc_info.value, ModelHTTPError)
assert exc_info.value.status_code == expected_status
@pytest.mark.skipif(not openai_imports(), reason='openai not installed')
@pytest.mark.parametrize(
'error_factory,expected_exc',
[
pytest.param(
lambda: OpenAIStatusError(message='Server error', response=_httpx_response(500), body={}),
ModelHTTPError,
id='http',
),
pytest.param(
lambda: OpenAIStatusError(message='SSE error', response=_httpx_response(200), body={}),
ModelAPIError,
id='status_lt_400',
),
pytest.param(
lambda: OpenAIConnectionError(request=httpx.Request('POST', 'https://api.openai.com')),
ModelAPIError,
id='connection',
),
],
)
async def test_openai_responses_midstream_error(
allow_model_requests: None, error_factory: Any, expected_exc: type[Exception]
):
"""Errors during stream iteration on OpenAI Responses model are wrapped correctly."""
stream = [_openai_responses_created_event(), error_factory()]
mock_client = MockOpenAIResponses.create_mock_stream(stream)
m = OpenAIResponsesModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client))
agent = Agent(m)
with pytest.raises(expected_exc):
async with agent.run_stream('hello') as result:
async for _ in result.stream_text():
pass
# ---------------------------------------------------------------------------
# Groq Tests
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not groq_imports(), reason='groq not installed')
async def test_groq_midstream_status_error(allow_model_requests: None):
"""APIStatusError during stream iteration is wrapped as ModelHTTPError."""
error = GroqStatusError(
message='Service unavailable',
response=_httpx_response(503),
body={'error': {'message': 'Service unavailable'}},
)
stream = [_groq_chunk(), error]
mock_client = MockGroq.create_mock_stream(stream)
m = GroqModel('llama-3.3-70b-versatile', provider=GroqProvider(groq_client=mock_client))
agent = Agent(m)
with pytest.raises(ModelHTTPError) as exc_info:
async with agent.run_stream('hello') as result:
async for _ in result.stream_text():
pass
assert exc_info.value.status_code == 503
assert exc_info.value.model_name == 'llama-3.3-70b-versatile'
@pytest.mark.skipif(not groq_imports(), reason='groq not installed')
@pytest.mark.parametrize(
'error_factory,expected_exc',
[
pytest.param(
lambda: GroqStatusError(
message='Rate limited',
response=_httpx_response(429),
body={'error': {'message': 'Rate limited'}},
),
ModelHTTPError,
id='http',
),
pytest.param(
lambda: GroqStatusError(message='SSE error', response=_httpx_response(200), body={}),
ModelAPIError,
id='status_lt_400',
),
pytest.param(
lambda: GroqConnectionError(request=httpx.Request('POST', 'https://api.groq.com')),
ModelAPIError,
id='connection',
),
],
)
async def test_groq_peek_error(allow_model_requests: None, error_factory: Any, expected_exc: type[Exception]):
"""Errors during peek() are wrapped in ModelHTTPError/ModelAPIError."""
stream = [error_factory()]
mock_client = MockGroq.create_mock_stream(stream)
m = GroqModel('llama-3.3-70b-versatile', provider=GroqProvider(groq_client=mock_client))
agent = Agent(m)
with pytest.raises(expected_exc) as exc_info:
async with agent.run_stream('hello'):
pass
if isinstance(exc_info.value, ModelHTTPError):
assert exc_info.value.status_code == 429
@pytest.mark.skipif(not groq_imports(), reason='groq not installed')
@pytest.mark.parametrize(
'error_factory,expected_exc',
[
pytest.param(
lambda: GroqStatusError(message='SSE error', response=_httpx_response(200), body={}),
ModelAPIError,
id='status_lt_400',
),
pytest.param(
lambda: GroqConnectionError(request=httpx.Request('POST', 'https://api.groq.com')),
ModelAPIError,
id='connection',
),
],
)
async def test_groq_midstream_non_http_error(
allow_model_requests: None, error_factory: Any, expected_exc: type[Exception]
):
"""APIStatusError with status<400 or APIConnectionError during iteration is wrapped as ModelAPIError."""
stream = [_groq_chunk(), error_factory()]
mock_client = MockGroq.create_mock_stream(stream)
m = GroqModel('llama-3.3-70b-versatile', provider=GroqProvider(groq_client=mock_client))
agent = Agent(m)
with pytest.raises(expected_exc):
async with agent.run_stream('hello') as result:
async for _ in result.stream_text():
pass
# ---------------------------------------------------------------------------
# Bedrock Tests
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not bedrock_imports(), reason='botocore not installed')
async def test_bedrock_stream_creation_error(allow_model_requests: None):
"""ClientError during stream creation is wrapped as ModelHTTPError."""
error = ClientError(
{
'Error': {'Code': 'ThrottlingException', 'Message': 'Rate exceeded'},
'ResponseMetadata': {
'RequestId': '',
'HostId': '',
'HTTPStatusCode': 429,
'HTTPHeaders': {},
'RetryAttempts': 0,
},
},
'converse_stream',
)
model = _bedrock_model_with_error(error)
agent = Agent(model)
with pytest.raises(ModelHTTPError) as exc_info:
async with agent.run_stream('hello'):
pass
assert exc_info.value.status_code == 429
assert exc_info.value.model_name == 'us.amazon.nova-micro-v1:0'
@pytest.mark.skipif(not bedrock_imports(), reason='botocore not installed')
async def test_bedrock_stream_non_http_error(allow_model_requests: None):
"""ClientError without HTTP status code is wrapped as ModelAPIError."""
error = ClientError(
{'Error': {'Code': 'TestException', 'Message': 'broken connection'}},
'converse_stream',
)
model = _bedrock_model_with_error(error)
agent = Agent(model)
with pytest.raises(ModelAPIError) as exc_info:
async with agent.run_stream('hello'):
pass
assert 'broken connection' in exc_info.value.message
@pytest.mark.skipif(not bedrock_imports(), reason='botocore not installed')
@pytest.mark.parametrize(
'error_factory,expected_exc,check_status',
[
pytest.param(
lambda: ClientError(
{
'Error': {'Code': 'ThrottlingException', 'Message': 'Rate exceeded'},
'ResponseMetadata': {
'RequestId': '',
'HostId': '',
'HTTPStatusCode': 429,
'HTTPHeaders': {},
'RetryAttempts': 0,
},
},
'converse_stream',
),
ModelHTTPError,
429,
id='http',
),
pytest.param(
lambda: ClientError({'Error': {'Code': 'TestException', 'Message': 'broken'}}, 'converse_stream'),
ModelAPIError,
None,
id='non_http',
),
],
)
async def test_bedrock_midstream_error(
allow_model_requests: None, error_factory: Any, expected_exc: type[Exception], check_status: int | None
):
"""ClientError during stream iteration is wrapped correctly."""
model = _bedrock_model_with_midstream_error(error_factory())
agent = Agent(model)
with pytest.raises(expected_exc) as exc_info:
async with agent.run_stream('hello') as result:
async for _ in result.stream_text():
pass
if check_status is not None:
assert isinstance(exc_info.value, ModelHTTPError)
assert exc_info.value.status_code == check_status
# ---------------------------------------------------------------------------
# HuggingFace Tests
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not huggingface_imports(), reason='huggingface_hub not installed')
async def test_huggingface_midstream_error(allow_model_requests: None):
"""HfHubHTTPError during stream iteration is wrapped as ModelHTTPError."""
hf_chunk = ChatCompletionStreamOutput(
id='x',
choices=[
ChatCompletionStreamOutputChoice(
index=0, delta=ChatCompletionStreamOutputDelta(content='hi', role='assistant'), finish_reason=None
)
],
created=1704067200,
model='hf-model',
system_fingerprint='',
)
error = HfHubHTTPError(
'Server error',
response=httpx.Response(500, request=httpx.Request('POST', 'https://api.hf.co'), content=b'error'),
)
stream = [hf_chunk, error]
mock_client = MockHuggingFace.create_stream_mock(stream)
m = HuggingFaceModel('test-model', provider=HuggingFaceProvider(hf_client=mock_client, api_key='test'))
agent = Agent(m)
with pytest.raises(ModelHTTPError) as exc_info:
async with agent.run_stream('hello') as result:
async for _ in result.stream_text():
pass
assert exc_info.value.status_code == 500
# ---------------------------------------------------------------------------
# Mistral Tests
# ---------------------------------------------------------------------------
def _mistral_chunk() -> MistralCompletionEvent:
return MistralCompletionEvent(
data=MistralCompletionChunk(
id='x',
choices=[
MistralCompletionResponseStreamChoice(
index=0, delta=MistralDeltaMessage(content='hi', role='assistant'), finish_reason=None
)
],
created=1704067200,
model='mistral-large',
object='chat.completion.chunk',
usage=MistralUsageInfo(prompt_tokens=1, completion_tokens=1, total_tokens=2),
)
)
def _mistral_client_raising(error: Any) -> Any:
"""Create a minimal Mistral client mock where stream_async raises the given error."""
async def _raise(**_: Any) -> None:
raise error
return SimpleNamespace(
chat=SimpleNamespace(stream_async=_raise),
sdk_configuration=SimpleNamespace(get_server_details=lambda: ('https://api.mistral.ai',)),
)
def _mistral_sdk_error(status_code: int, message: str) -> SDKError:
return SDKError(message, httpx.Response(status_code, request=httpx.Request('POST', 'https://api.mistral.ai')))
@pytest.mark.skipif(not mistral_imports(), reason='mistral not installed')
@pytest.mark.parametrize(
'error_factory,expected_exc,check_status',
[
pytest.param(lambda: _mistral_sdk_error(500, 'Server error'), ModelHTTPError, 500, id='http'),
pytest.param(lambda: _mistral_sdk_error(200, 'SSE error'), ModelAPIError, None, id='non_http'),
],
)
async def test_mistral_stream_creation_error(
allow_model_requests: None, error_factory: Any, expected_exc: type[Exception], check_status: int | None
):
"""SDKError during stream creation is wrapped correctly."""
mock_client = _mistral_client_raising(error_factory())
m = MistralModel('mistral-large-latest', provider=MistralProvider(mistral_client=mock_client))
agent = Agent(m)
with pytest.raises(expected_exc) as exc_info:
async with agent.run_stream('hello'):
pass
if check_status is not None:
assert isinstance(exc_info.value, ModelHTTPError)
assert exc_info.value.status_code == check_status
@pytest.mark.skipif(not mistral_imports(), reason='mistral not installed')
@pytest.mark.parametrize(
'error_factory,expected_exc,check_status',
[
pytest.param(lambda: _mistral_sdk_error(500, 'Server error'), ModelHTTPError, 500, id='http'),
pytest.param(lambda: _mistral_sdk_error(200, 'SSE error'), ModelAPIError, None, id='non_http'),
],
)
async def test_mistral_midstream_error(
allow_model_requests: None, error_factory: Any, expected_exc: type[Exception], check_status: int | None
):
"""SDKError during stream iteration is wrapped correctly."""
mock_client = MockMistralAI.create_stream_mock([_mistral_chunk(), error_factory()])
m = MistralModel('mistral-large-latest', provider=MistralProvider(mistral_client=mock_client))
agent = Agent(m)
with pytest.raises(expected_exc) as exc_info:
async with agent.run_stream('hello') as result:
async for _ in result.stream_text():
pass
if check_status is not None:
assert isinstance(exc_info.value, ModelHTTPError)
assert exc_info.value.status_code == check_status
# ---------------------------------------------------------------------------
# xAI Tests
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not xai_imports(), reason='xai-sdk not installed')
@pytest.mark.parametrize(
'grpc_code_name,expected_exc,expected_status',
[
pytest.param('UNAVAILABLE', ModelHTTPError, 503, id='http_mappable'),
pytest.param('CANCELLED', ModelAPIError, None, id='unmapped'),
],
)
async def test_xai_peek_error(
allow_model_requests: None, grpc_code_name: str, expected_exc: type[Exception], expected_status: int | None
):
"""gRPC errors during stream peek are wrapped correctly."""
error = _StubRpcError(getattr(grpc.StatusCode, grpc_code_name), 'gRPC error')
stream_data = [[error]]
mock_client = MockXai.create_mock_stream(stream_data) # pyright: ignore[reportArgumentType]
m = XaiModel('grok-3-mini', provider=XaiProvider(xai_client=mock_client))
agent = Agent(m)
with pytest.raises(expected_exc) as exc_info:
async with agent.run_stream('hello'):
pass
if expected_status is not None:
assert isinstance(exc_info.value, ModelHTTPError)
assert exc_info.value.status_code == expected_status
@pytest.mark.skipif(not xai_imports(), reason='xai-sdk not installed')
@pytest.mark.parametrize(
'grpc_code_name,expected_exc,expected_status',
[
pytest.param('UNAVAILABLE', ModelHTTPError, 503, id='http_mappable'),
pytest.param('CANCELLED', ModelAPIError, None, id='unmapped'),
],
)
async def test_xai_request_error(
allow_model_requests: None, grpc_code_name: str, expected_exc: type[Exception], expected_status: int | None
):
"""gRPC errors during non-streaming request are wrapped correctly."""
error = _StubRpcError(getattr(grpc.StatusCode, grpc_code_name), 'gRPC error')
mock_client = MockXai.create_mock([error])
m = XaiModel('grok-3-mini', provider=XaiProvider(xai_client=mock_client))
agent = Agent(m)
with pytest.raises(expected_exc) as exc_info:
await agent.run('hello')
if expected_status is not None:
assert isinstance(exc_info.value, ModelHTTPError)
assert exc_info.value.status_code == expected_status
@pytest.mark.skipif(not xai_imports(), reason='xai-sdk not installed')
@pytest.mark.parametrize(
'grpc_code_name,expected_exc,expected_status',
[
pytest.param('UNAVAILABLE', ModelHTTPError, 503, id='http_mappable'),
pytest.param('CANCELLED', ModelAPIError, None, id='unmapped'),
],
)
async def test_xai_midstream_error(
allow_model_requests: None, grpc_code_name: str, expected_exc: type[Exception], expected_status: int | None
):
"""gRPC errors during stream iteration are wrapped correctly."""
error = _StubRpcError(getattr(grpc.StatusCode, grpc_code_name), 'gRPC error')
stream_data = [[get_grok_text_chunk('hello'), error]]
mock_client = MockXai.create_mock_stream(stream_data) # pyright: ignore[reportArgumentType]
m = XaiModel('grok-3-mini', provider=XaiProvider(xai_client=mock_client))
agent = Agent(m)
with pytest.raises(expected_exc) as exc_info:
async with agent.run_stream('hello') as result:
async for _ in result.stream_text():
pass
if expected_status is not None:
assert isinstance(exc_info.value, ModelHTTPError)
assert exc_info.value.status_code == expected_status
# ---------------------------------------------------------------------------
# FallbackModel Integration Tests
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not anthropic_imports() or not openai_imports(), reason='anthropic+openai not installed')
async def test_fallback_model_streaming_error_triggers_fallback(allow_model_requests: None):
"""FallbackModel falls back to the next model when the first model errors during peek."""
# First model: Anthropic that errors on peek (first event is the error)
anthropic_error = AnthropicStatusError(
message='Overloaded',
response=_httpx_response(529),
body={'type': 'error', 'error': {'type': 'overloaded_error'}},
)
anthropic_stream = [anthropic_error]
anthropic_mock = MockAnthropic.create_stream_mock(anthropic_stream)
anthropic_model = AnthropicModel('claude-haiku-4-5', provider=AnthropicProvider(anthropic_client=anthropic_mock))
# Second model: OpenAI that succeeds
openai_finish_chunk = ChatCompletionChunk(
id='chatcmpl-2',
choices=[Choice(delta=ChoiceDelta(content=None), index=0, finish_reason='stop')],
created=1234567890,
model='gpt-4o',
object='chat.completion.chunk',
)
openai_stream = [_openai_chunk(), openai_finish_chunk]
openai_mock = MockOpenAI.create_mock_stream(openai_stream)
openai_model = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=openai_mock))
fallback = FallbackModel(anthropic_model, openai_model)
agent = Agent(fallback)
async with agent.run_stream('hello') as result:
text = await result.get_output()
assert text == 'hello'