diff --git a/tests/unit/vertexai/test_generative_models.py b/tests/unit/vertexai/test_generative_models.py index dec6f9c815..14fa003ba7 100644 --- a/tests/unit/vertexai/test_generative_models.py +++ b/tests/unit/vertexai/test_generative_models.py @@ -497,6 +497,58 @@ def test_generate_content_streaming(self, generative_models: generative_models): for chunk in stream: assert chunk.text + @mock.patch.object( + target=prediction_service.PredictionServiceClient, + attribute="generate_content", + new=mock_generate_content, + ) + @pytest.mark.parametrize( + "generative_models", + [generative_models, preview_generative_models], + ) + def test_generate_content_response_accessor_errors( + self, generative_models: generative_models + ): + """Checks that the exception text contains response information.""" + model = generative_models.GenerativeModel("gemini-pro") + + # Case when response has no candidates + response1 = model.generate_content("Please block with block_reason=OTHER") + + assert response1.prompt_feedback.block_reason.name == "OTHER" + + with pytest.raises(ValueError) as e: + _ = response1.text + assert e.match("no candidates") + assert e.match("prompt_feedback") + + # Case when response candidate content has no parts + response2 = model.generate_content("Please fail!") + + with pytest.raises(ValueError) as e: + _ = response2.text + assert e.match("no parts") + assert e.match("finish_reason") + + with pytest.raises(ValueError) as e: + _ = response2.candidates[0].text + assert e.match("no parts") + assert e.match("finish_reason") + + # Case when response candidate content part has no text + weather_tool = generative_models.Tool( + function_declarations=[ + generative_models.FunctionDeclaration.from_func(get_current_weather) + ], + ) + response3 = model.generate_content( + "What's the weather like in Boston?", tools=[weather_tool] + ) + with pytest.raises(ValueError) as e: + print(response3.text) + assert e.match("no text") + assert e.match("function_call") + @mock.patch.object( target=prediction_service.PredictionServiceClient, attribute="generate_content", diff --git a/vertexai/generative_models/_generative_models.py b/vertexai/generative_models/_generative_models.py index d253291e07..2a6ff76114 100644 --- a/vertexai/generative_models/_generative_models.py +++ b/vertexai/generative_models/_generative_models.py @@ -17,6 +17,7 @@ import copy import io +import json import pathlib from typing import ( Any, @@ -1658,8 +1659,23 @@ def text(self) -> str: " Use `response.candidate[i].text` to get text of a particular candidate." ) if not self.candidates: - raise ValueError("Response has no candidates (and no text).") - return self.candidates[0].text + raise ValueError( + "Response has no candidates (and thus no text)." + " The response is likely blocked by the safety filters.\n" + "Response:\n" + + _dict_to_pretty_string(self.to_dict()) + ) + try: + return self.candidates[0].text + except (ValueError, AttributeError) as e: + # Enrich the error message with the whole Response. + # The Candidate object does not have full information. + raise ValueError( + "Cannot get the response text.\n" + f"{e}\n" + "Response:\n" + + _dict_to_pretty_string(self.to_dict()) + ) from e @property def prompt_feedback( @@ -1728,7 +1744,17 @@ def citation_metadata(self) -> gapic_content_types.CitationMetadata: # GenerationPart properties @property def text(self) -> str: - return self.content.text + try: + return self.content.text + except (ValueError, AttributeError) as e: + # Enrich the error message with the whole Candidate. + # The Content object does not have full information. + raise ValueError( + "Cannot get the Candidate text.\n" + f"{e}\n" + "Candidate:\n" + + _dict_to_pretty_string(self.to_dict()) + ) from e @property def function_calls(self) -> Sequence[gapic_tool_types.FunctionCall]: @@ -1799,7 +1825,12 @@ def text(self) -> str: if len(self.parts) > 1: raise ValueError("Multiple content parts are not supported.") if not self.parts: - raise AttributeError("Content has no parts.") + raise ValueError( + "Response candidate content has no parts (and thus no text)." + " The candidate is likely blocked by the safety filters.\n" + "Content:\n" + + _dict_to_pretty_string(self.to_dict()) + ) return self.parts[0].text @@ -1886,7 +1917,11 @@ def to_dict(self) -> Dict[str, Any]: @property def text(self) -> str: if "text" not in self._raw_part: - raise AttributeError("Part has no text.") + raise AttributeError( + "Response candidate content part has no text.\n" + "Part:\n" + + _dict_to_pretty_string(self.to_dict()) + ) return self._raw_part.text @property @@ -2188,6 +2223,11 @@ def _append_gapic_part( base_part._pb = copy.deepcopy(new_part._pb) +def _dict_to_pretty_string(d: dict) -> str: + """Format dict as a pretty-printed JSON string.""" + return json.dumps(d, indent=2) + + _FORMAT_TO_MIME_TYPE = { "png": "image/png", "jpeg": "image/jpeg",