Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4ac81d4
add content filtering exceptions
AlanPonnachan Dec 3, 2025
658f407
Merge branch 'main' into feature/content-filtering-exceptions
AlanPonnachan Dec 3, 2025
404a833
resolve issues
AlanPonnachan Dec 5, 2025
3771c79
add docs
AlanPonnachan Dec 5, 2025
28e20c8
Merge branch 'main' into feature/content-filtering-exceptions
AlanPonnachan Dec 5, 2025
70bcb74
test coverage
AlanPonnachan Dec 5, 2025
e861a50
improve tests
AlanPonnachan Dec 5, 2025
51506b2
resolve issues
AlanPonnachan Dec 6, 2025
b67d216
test coverage
AlanPonnachan Dec 6, 2025
4ac1608
fix ci fails
AlanPonnachan Dec 6, 2025
264f9e6
make consistent for all models
AlanPonnachan Dec 11, 2025
ed242de
Merge branch 'main' into feature/content-filtering-exceptions
AlanPonnachan Dec 11, 2025
a0a85cb
improvements
AlanPonnachan Dec 13, 2025
0327196
Merge branch 'main' into feature/content-filtering-exceptions
AlanPonnachan Dec 13, 2025
bba451d
test coverage
AlanPonnachan Dec 13, 2025
d969325
test coverage
AlanPonnachan Dec 13, 2025
7561b63
improvements
AlanPonnachan Dec 18, 2025
66daa87
remove unwanted tests
AlanPonnachan Dec 18, 2025
a1b87dd
Merge branch 'main' into feature/content-filtering-exceptions
AlanPonnachan Dec 18, 2025
6ad2c14
fix format errors
AlanPonnachan Dec 18, 2025
3e37ae6
test coverage
AlanPonnachan Dec 18, 2025
5522fbb
test coverage
AlanPonnachan Dec 18, 2025
c53e558
improvements
AlanPonnachan Dec 19, 2025
a315a54
improvements
AlanPonnachan Dec 19, 2025
1f2c6c8
Merge branch 'main' into feature/content-filtering-exceptions
AlanPonnachan Dec 19, 2025
3e15bf4
improvements
AlanPonnachan Dec 20, 2025
b24affa
Merge branch 'main' into feature/content-filtering-exceptions
AlanPonnachan Dec 20, 2025
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
11 changes: 11 additions & 0 deletions pydantic_ai_slim/pydantic_ai/_agent_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,17 @@ async def _run_stream() -> AsyncIterator[_messages.HandleResponseEvent]: # noqa
f'Model token limit ({max_tokens or "provider default"}) exceeded before any response was generated. Increase the `max_tokens` model setting, or simplify the prompt to result in a shorter response that will fit within the limit.'
)

# Check for content filter on empty response
if self.model_response.finish_reason == 'content_filter':
details = self.model_response.provider_details or {}
reason = details.get('finish_reason', 'content_filter')

body = _messages.ModelMessagesTypeAdapter.dump_json([self.model_response]).decode()

raise exceptions.ContentFilterError(
f"Content filter triggered. Finish reason: '{reason}'", body=body
)

# we got an empty response.
# this sometimes happens with anthropic (and perhaps other models)
# when the model has already returned text along side tool calls
Expand Down
5 changes: 5 additions & 0 deletions pydantic_ai_slim/pydantic_ai/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
'UsageLimitExceeded',
'ModelAPIError',
'ModelHTTPError',
'ContentFilterError',
'IncompleteToolCall',
'FallbackExceptionGroup',
)
Expand Down Expand Up @@ -154,6 +155,10 @@ def __str__(self) -> str:
return self.message


class ContentFilterError(UnexpectedModelBehavior):
"""Raised when content filtering is triggered by the model provider resulting in an empty response."""


class ModelAPIError(AgentRunError):
"""Raised when a model provider API request fails."""

Expand Down
22 changes: 11 additions & 11 deletions pydantic_ai_slim/pydantic_ai/models/google.py
Original file line number Diff line number Diff line change
Expand Up @@ -569,14 +569,13 @@ def _process_response(self, response: GenerateContentResponse) -> ModelResponse:
raw_finish_reason = candidate.finish_reason
if raw_finish_reason: # pragma: no branch
vendor_details = {'finish_reason': raw_finish_reason.value}
# Add safety ratings to provider details
if candidate.safety_ratings:
vendor_details['safety_ratings'] = [r.model_dump(by_alias=True) for r in candidate.safety_ratings]
finish_reason = _FINISH_REASON_MAP.get(raw_finish_reason)

if candidate.content is None or candidate.content.parts is None:
if finish_reason == 'content_filter' and raw_finish_reason:
raise UnexpectedModelBehavior(
f'Content filter {raw_finish_reason.value!r} triggered', response.model_dump_json()
)
parts = [] # pragma: no cover
parts = []
else:
parts = candidate.content.parts or []

Expand Down Expand Up @@ -752,6 +751,12 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
raw_finish_reason = candidate.finish_reason
if raw_finish_reason:
self.provider_details = {'finish_reason': raw_finish_reason.value}

if candidate.safety_ratings:
self.provider_details['safety_ratings'] = [
r.model_dump(by_alias=True) for r in candidate.safety_ratings
]

self.finish_reason = _FINISH_REASON_MAP.get(raw_finish_reason)

# Google streams the grounding metadata (including the web search queries and results)
Expand All @@ -777,12 +782,7 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
yield self._parts_manager.handle_part(vendor_part_id=uuid4(), part=web_fetch_return)

if candidate.content is None or candidate.content.parts is None:
if self.finish_reason == 'content_filter' and raw_finish_reason: # pragma: no cover
raise UnexpectedModelBehavior(
f'Content filter {raw_finish_reason.value!r} triggered', chunk.model_dump_json()
)
else: # pragma: no cover
continue
continue

parts = candidate.content.parts
if not parts:
Expand Down
85 changes: 81 additions & 4 deletions pydantic_ai_slim/pydantic_ai/models/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from functools import cached_property
from typing import Any, Literal, cast, overload

from pydantic import ValidationError
from pydantic import BaseModel, ValidationError
from pydantic_core import to_json
from typing_extensions import assert_never, deprecated

Expand Down Expand Up @@ -164,6 +164,36 @@
_OPENAI_IMAGE_SIZES: tuple[_OPENAI_IMAGE_SIZE, ...] = _utils.get_args(_OPENAI_IMAGE_SIZE)


class _AzureContentFilterResultDetail(BaseModel):
filtered: bool
severity: str | None = None
detected: bool | None = None


class _AzureContentFilterResult(BaseModel):
hate: _AzureContentFilterResultDetail | None = None
self_harm: _AzureContentFilterResultDetail | None = None
sexual: _AzureContentFilterResultDetail | None = None
violence: _AzureContentFilterResultDetail | None = None
jailbreak: _AzureContentFilterResultDetail | None = None
profanity: _AzureContentFilterResultDetail | None = None


class _AzureInnerError(BaseModel):
code: str
content_filter_result: _AzureContentFilterResult


class _AzureError(BaseModel):
code: str
message: str
innererror: _AzureInnerError | None = None


class _AzureErrorResponse(BaseModel):
error: _AzureError


def _resolve_openai_image_generation_size(
tool: ImageGenerationTool,
) -> _OPENAI_IMAGE_SIZE:
Expand Down Expand Up @@ -194,6 +224,36 @@ def _resolve_openai_image_generation_size(
return mapped_size


def _check_azure_content_filter(e: APIStatusError, system: str, model_name: str) -> ModelResponse | None:
"""Check if the error is an Azure content filter error."""
# Assign to Any to avoid 'dict[Unknown, Unknown]' inference in strict mode
body_any: Any = e.body

if system == 'azure' and e.status_code == 400 and isinstance(body_any, dict):
try:
error_data = _AzureErrorResponse.model_validate(body_any)

if error_data.error.code == 'content_filter':
provider_details: dict[str, Any] = {'finish_reason': 'content_filter'}

if error_data.error.innererror:
provider_details['content_filter_result'] = (
error_data.error.innererror.content_filter_result.model_dump(exclude_none=True)
)

return ModelResponse(
parts=[], # Empty parts to trigger content filter error in agent graph
model_name=model_name,
timestamp=_utils.now_utc(),
provider_name=system,
finish_reason='content_filter',
provider_details=provider_details,
)
except ValidationError:
pass
return None


class OpenAIChatModelSettings(ModelSettings, total=False):
"""Settings used for an OpenAI model request."""

Expand Down Expand Up @@ -532,6 +592,11 @@ async def request(
response = await self._completions_create(
messages, False, cast(OpenAIChatModelSettings, model_settings or {}), model_request_parameters
)

# Handle ModelResponse returned directly (for content filters)
if isinstance(response, ModelResponse):
return response

model_response = self._process_response(response)
return model_response

Expand Down Expand Up @@ -570,15 +635,15 @@ async def _completions_create(
stream: Literal[False],
model_settings: OpenAIChatModelSettings,
model_request_parameters: ModelRequestParameters,
) -> chat.ChatCompletion: ...
) -> chat.ChatCompletion | ModelResponse: ...

async def _completions_create(
self,
messages: list[ModelMessage],
stream: bool,
model_settings: OpenAIChatModelSettings,
model_request_parameters: ModelRequestParameters,
) -> chat.ChatCompletion | AsyncStream[ChatCompletionChunk]:
) -> chat.ChatCompletion | AsyncStream[ChatCompletionChunk] | ModelResponse:
tools = self._get_tools(model_request_parameters)
web_search_options = self._get_web_search_options(model_request_parameters)

Expand Down Expand Up @@ -642,6 +707,8 @@ async def _completions_create(
extra_body=model_settings.get('extra_body'),
)
except APIStatusError as e:
if model_response := _check_azure_content_filter(e, self.system, self.model_name):
return model_response
if (status_code := e.status_code) >= 400:
raise ModelHTTPError(status_code=status_code, model_name=self.model_name, body=e.body) from e
raise # pragma: lax no cover
Expand Down Expand Up @@ -689,6 +756,7 @@ def _process_response(self, response: chat.ChatCompletion | str) -> ModelRespons
raise UnexpectedModelBehavior(f'Invalid response from {self.system} chat completions endpoint: {e}') from e

choice = response.choices[0]

items: list[ModelResponsePart] = []

if thinking_parts := self._process_thinking(choice.message):
Expand Down Expand Up @@ -1217,6 +1285,11 @@ async def request(
response = await self._responses_create(
messages, False, cast(OpenAIResponsesModelSettings, model_settings or {}), model_request_parameters
)

# Handle ModelResponse
if isinstance(response, ModelResponse):
return response

return self._process_response(response, model_request_parameters)

@asynccontextmanager
Expand Down Expand Up @@ -1397,7 +1470,7 @@ async def _responses_create( # noqa: C901
stream: bool,
model_settings: OpenAIResponsesModelSettings,
model_request_parameters: ModelRequestParameters,
) -> responses.Response | AsyncStream[responses.ResponseStreamEvent]:
) -> responses.Response | AsyncStream[responses.ResponseStreamEvent] | ModelResponse:
tools = (
self._get_builtin_tools(model_request_parameters)
+ list(model_settings.get('openai_builtin_tools', []))
Expand Down Expand Up @@ -1499,6 +1572,9 @@ async def _responses_create( # noqa: C901
extra_body=model_settings.get('extra_body'),
)
except APIStatusError as e:
if model_response := _check_azure_content_filter(e, self.system, self.model_name):
return model_response

if (status_code := e.status_code) >= 400:
raise ModelHTTPError(status_code=status_code, model_name=self.model_name, body=e.body) from e
raise # pragma: lax no cover
Expand Down Expand Up @@ -2190,6 +2266,7 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
raw_finish_reason = (
details.reason if (details := chunk.response.incomplete_details) else chunk.response.status
)

if raw_finish_reason: # pragma: no branch
self.provider_details = {'finish_reason': raw_finish_reason}
self.finish_reason = _RESPONSES_FINISH_REASON_MAP.get(raw_finish_reason)
Expand Down
28 changes: 26 additions & 2 deletions tests/models/test_google.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import asyncio
import base64
import datetime
import json
import os
import re
import tempfile
Expand Down Expand Up @@ -58,7 +59,13 @@
WebFetchTool,
WebSearchTool,
)
from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError, ModelRetry, UnexpectedModelBehavior, UserError
from pydantic_ai.exceptions import (
ContentFilterError,
ModelAPIError,
ModelHTTPError,
ModelRetry,
UserError,
)
from pydantic_ai.messages import (
BuiltinToolCallEvent, # pyright: ignore[reportDeprecated]
BuiltinToolResultEvent, # pyright: ignore[reportDeprecated]
Expand Down Expand Up @@ -998,9 +1005,26 @@ async def test_google_model_safety_settings(allow_model_requests: None, google_p
)
agent = Agent(m, instructions='You hate the world!', model_settings=settings)

with pytest.raises(UnexpectedModelBehavior, match="Content filter 'SAFETY' triggered"):
with pytest.raises(
ContentFilterError,
match="Content filter triggered. Finish reason: 'SAFETY'",
) as exc_info:
await agent.run('Tell me a joke about a Brazilians.')

# Verify that we captured the safety settings in the exception body
assert exc_info.value.body is not None
body_json = json.loads(exc_info.value.body)
assert len(body_json) == 1
response_msg = body_json[0]

assert response_msg['finish_reason'] == 'content_filter'
details = response_msg['provider_details']
assert details['finish_reason'] == 'SAFETY'
assert len(details['safety_ratings']) > 0
# The first rating should reflect the blocking
assert details['safety_ratings'][0]['category'] == 'HARM_CATEGORY_HATE_SPEECH'
assert details['safety_ratings'][0]['blocked'] is True


async def test_google_model_web_search_tool(allow_model_requests: None, google_provider: GoogleProvider):
m = GoogleModel('gemini-2.5-pro', provider=google_provider)
Expand Down
Loading
Loading