diff --git a/google/generativeai/client.py b/google/generativeai/client.py index 01d0a003b..938018b96 100644 --- a/google/generativeai/client.py +++ b/google/generativeai/client.py @@ -2,9 +2,9 @@ import os import contextlib +import inspect import dataclasses import pathlib -import types from typing import Any, cast from collections.abc import Sequence import httplib2 @@ -30,6 +30,21 @@ __version__ = "0.0.0" USER_AGENT = "genai-py" + +#### Caution! #### +# - It would make sense for the discovery URL to respect the client_options.endpoint setting. +# - That would make testing Files on the staging server possible. +# - We tried fixing this once, but broke colab in the process because their endpoint didn't forward the discovery +# requests. https://github.com/google-gemini/generative-ai-python/pull/333 +# - Kaggle would have a similar problem (b/362278209). +# - I think their proxy would forward the discovery traffic. +# - But they don't need to intercept the files-service at all, and uploads of large files could overload them. +# - Do the scotty uploads go to the same domain? +# - If you do route the discovery call to kaggle, be sure to attach the default_metadata (they need it). +# - One solution to all this would be if configure could take overrides per service. +# - set client_options.endpoint, but use a different endpoint for file service? It's not clear how best to do that +# through the file service. +################## GENAI_API_DISCOVERY_URL = "https://generativelanguage.googleapis.com/$discovery/rest" @@ -50,7 +65,7 @@ def __init__(self, *args, **kwargs): self._discovery_api = None super().__init__(*args, **kwargs) - def _setup_discovery_api(self): + def _setup_discovery_api(self, metadata: dict | Sequence[tuple[str, str]] = ()): api_key = self._client_options.api_key if api_key is None: raise ValueError( @@ -61,6 +76,7 @@ def _setup_discovery_api(self): http=httplib2.Http(), postproc=lambda resp, content: (resp, content), uri=f"{GENAI_API_DISCOVERY_URL}?version=v1beta&key={api_key}", + headers=dict(metadata), ) response, content = request.execute() request.http.close() @@ -78,9 +94,10 @@ def create_file( name: str | None = None, display_name: str | None = None, resumable: bool = True, + metadata: Sequence[tuple[str, str]] = (), ) -> protos.File: if self._discovery_api is None: - self._setup_discovery_api() + self._setup_discovery_api(metadata) file = {} if name is not None: @@ -92,6 +109,8 @@ def create_file( filename=path, mimetype=mime_type, resumable=resumable ) request = self._discovery_api.media().upload(body={"file": file}, media_body=media) + for key, value in metadata: + request.headers[key] = value result = request.execute() return self.get_file({"name": result["file"]["name"]}) @@ -226,16 +245,14 @@ def make_client(self, name): def keep(name, f): if name.startswith("_"): return False - elif name == "create_file": - return False - elif not isinstance(f, types.FunctionType): - return False - elif isinstance(f, classmethod): + + if not callable(f): return False - elif isinstance(f, staticmethod): + + if "metadata" not in inspect.signature(f).parameters.keys(): return False - else: - return True + + return True def add_default_metadata_wrapper(f): def call(*args, metadata=(), **kwargs): @@ -244,7 +261,7 @@ def call(*args, metadata=(), **kwargs): return call - for name, value in cls.__dict__.items(): + for name, value in inspect.getmembers(cls): if not keep(name, value): continue f = getattr(client, name) diff --git a/google/generativeai/types/generation_types.py b/google/generativeai/types/generation_types.py index 84689a922..23e7fb1d8 100644 --- a/google/generativeai/types/generation_types.py +++ b/google/generativeai/types/generation_types.py @@ -412,14 +412,22 @@ def parts(self): """ candidates = self.candidates if not candidates: - raise ValueError( + msg = ( "Invalid operation: The `response.parts` quick accessor requires a single candidate, " - "but none were returned. Please check the `response.prompt_feedback` to determine if the prompt was blocked." + "but but `response.candidates` is empty." ) + if self.prompt_feedback: + raise ValueError( + msg + "\nThis appears to be caused by a blocked prompt, " + f"see `response.prompt_feedback`: {self.prompt_feedback}" + ) + else: + raise ValueError(msg) + if len(candidates) > 1: raise ValueError( - "Invalid operation: The `response.parts` quick accessor requires a single candidate. " - "For multiple candidates, please use `result.candidates[index].text`." + "Invalid operation: The `response.parts` quick accessor retrieves the parts for a single candidate. " + "This response contains multiple candidates, please use `result.candidates[index].text`." ) parts = candidates[0].content.parts return parts @@ -433,10 +441,53 @@ def text(self): """ parts = self.parts if not parts: - raise ValueError( - "Invalid operation: The `response.text` quick accessor requires the response to contain a valid `Part`, " - "but none were returned. Please check the `candidate.safety_ratings` to determine if the response was blocked." + candidate = self.candidates[0] + + fr = candidate.finish_reason + FinishReason = protos.Candidate.FinishReason + + msg = ( + "Invalid operation: The `response.text` quick accessor requires the response to contain a valid " + "`Part`, but none were returned. The candidate's " + f"[finish_reason](https://ai.google.dev/api/generate-content#finishreason) is {fr}." ) + if candidate.finish_message: + msg += 'The `finish_message` is "{candidate.finish_message}".' + + if fr is FinishReason.FINISH_REASON_UNSPECIFIED: + raise ValueError(msg) + elif fr is FinishReason.STOP: + raise ValueError(msg) + elif fr is FinishReason.MAX_TOKENS: + raise ValueError(msg) + elif fr is FinishReason.SAFETY: + raise ValueError( + msg + f" The candidate's safety_ratings are: {candidate.safety_ratings}.", + candidate.safety_ratings, + ) + elif fr is FinishReason.RECITATION: + raise ValueError( + msg + " Meaning that the model was reciting from copyrighted material." + ) + elif fr is FinishReason.LANGUAGE: + raise ValueError(msg + " Meaning the response was using an unsupported language.") + elif fr is FinishReason.OTHER: + raise ValueError(msg) + elif fr is FinishReason.BLOCKLIST: + raise ValueError(msg) + elif fr is FinishReason.PROHIBITED_CONTENT: + raise ValueError(msg) + elif fr is FinishReason.SPII: + raise ValueError(msg + " SPII - Sensitive Personally Identifiable Information.") + elif fr is FinishReason.MALFORMED_FUNCTION_CALL: + raise ValueError( + msg + " Meaning that model generated a `FunctionCall` that was invalid. " + "Setting the " + "[Function calling mode](https://ai.google.dev/gemini-api/docs/function-calling#function_calling_mode) " + "to `ANY` can fix this because it enables constrained decoding." + ) + else: + raise ValueError(msg) texts = [] for part in parts: diff --git a/google/generativeai/version.py b/google/generativeai/version.py index a5bd48443..18d6452a5 100644 --- a/google/generativeai/version.py +++ b/google/generativeai/version.py @@ -14,4 +14,4 @@ # limitations under the License. from __future__ import annotations -__version__ = "0.8.0" +__version__ = "0.8.1" diff --git a/tests/test_generative_models.py b/tests/test_generative_models.py index cccea9d48..79c1ac36f 100644 --- a/tests/test_generative_models.py +++ b/tests/test_generative_models.py @@ -1120,13 +1120,13 @@ def test_repr_error_info_for_chat_streaming_unexpected_stop(self): "usage_metadata": {} }), ), - error= index: 0 - content { + error= content { parts { text: "abc" } } finish_reason: SAFETY + index: 0 citation_metadata { } """