From b784b92f1219ba0247349fdbc0010a72cfda89ab Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 24 Oct 2025 00:30:55 +0000 Subject: [PATCH] Raise clear error when any Google content filter is hit resulting in empty response --- docs/agents.md | 2 +- pydantic_ai_slim/pydantic_ai/models/google.py | 40 ++++++++++++------- tests/models/test_google.py | 2 +- tests/test_examples.py | 2 +- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index cd917feafc..ab3f658b7a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -736,7 +736,7 @@ try: except UnexpectedModelBehavior as e: print(e) # (1)! """ - Safety settings triggered, body: + Content filter 'SAFETY' triggered, body: """ ``` diff --git a/pydantic_ai_slim/pydantic_ai/models/google.py b/pydantic_ai_slim/pydantic_ai/models/google.py index e0a9197449..8411c3b3cf 100644 --- a/pydantic_ai_slim/pydantic_ai/models/google.py +++ b/pydantic_ai_slim/pydantic_ai/models/google.py @@ -126,6 +126,8 @@ GoogleFinishReason.MALFORMED_FUNCTION_CALL: 'error', GoogleFinishReason.IMAGE_SAFETY: 'content_filter', GoogleFinishReason.UNEXPECTED_TOOL_CALL: 'error', + GoogleFinishReason.IMAGE_PROHIBITED_CONTENT: 'content_filter', + GoogleFinishReason.NO_IMAGE: 'error', } @@ -453,23 +455,28 @@ async def _build_content_and_config( def _process_response(self, response: GenerateContentResponse) -> ModelResponse: if not response.candidates: raise UnexpectedModelBehavior('Expected at least one candidate in Gemini response') # pragma: no cover + candidate = response.candidates[0] - if candidate.content is None or candidate.content.parts is None: - if candidate.finish_reason == 'SAFETY': - raise UnexpectedModelBehavior('Safety settings triggered', str(response)) - else: - raise UnexpectedModelBehavior( - 'Content field missing from Gemini response', str(response) - ) # pragma: no cover - parts = candidate.content.parts or [] vendor_id = response.response_id vendor_details: dict[str, Any] | None = None finish_reason: FinishReason | None = None - if raw_finish_reason := candidate.finish_reason: # pragma: no branch + raw_finish_reason = candidate.finish_reason + if raw_finish_reason: # pragma: no branch vendor_details = {'finish_reason': raw_finish_reason.value} 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() + ) + else: + raise UnexpectedModelBehavior( + 'Content field missing from Gemini response', response.model_dump_json() + ) # pragma: no cover + parts = candidate.content.parts or [] + usage = _metadata_as_usage(response) return _process_response_from_parts( parts, @@ -623,7 +630,8 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: if chunk.response_id: # pragma: no branch self.provider_response_id = chunk.response_id - if raw_finish_reason := candidate.finish_reason: + raw_finish_reason = candidate.finish_reason + if raw_finish_reason: self.provider_details = {'finish_reason': raw_finish_reason.value} self.finish_reason = _FINISH_REASON_MAP.get(raw_finish_reason) @@ -641,13 +649,17 @@ async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: # ) if candidate.content is None or candidate.content.parts is None: - if candidate.finish_reason == 'STOP': # pragma: no cover + if self.finish_reason == 'stop': # pragma: no cover # Normal completion - skip this chunk continue - elif candidate.finish_reason == 'SAFETY': # pragma: no cover - raise UnexpectedModelBehavior('Safety settings triggered', str(chunk)) + elif 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 - raise UnexpectedModelBehavior('Content field missing from streaming Gemini response', str(chunk)) + raise UnexpectedModelBehavior( + 'Content field missing from streaming Gemini response', chunk.model_dump_json() + ) parts = candidate.content.parts if not parts: diff --git a/tests/models/test_google.py b/tests/models/test_google.py index 8d2f5354bc..89150af60d 100644 --- a/tests/models/test_google.py +++ b/tests/models/test_google.py @@ -851,7 +851,7 @@ 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='Safety settings triggered'): + with pytest.raises(UnexpectedModelBehavior, match="Content filter 'SAFETY' triggered"): await agent.run('Tell me a joke about a Brazilians.') diff --git a/tests/test_examples.py b/tests/test_examples.py index 12b0c745ad..c95bcee0af 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -564,7 +564,7 @@ async def model_logic( # noqa: C901 ] ) elif m.content.startswith('Write a list of 5 very rude things that I might say'): - raise UnexpectedModelBehavior('Safety settings triggered', body='') + raise UnexpectedModelBehavior("Content filter 'SAFETY' triggered", body='') elif m.content.startswith('\n John Doe'): return ModelResponse( parts=[ToolCallPart(tool_name='final_result_EmailOk', args={}, tool_call_id='pyd_ai_tool_call_id')]