diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 59d88f44..d8babedb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,6 +37,7 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} GCP_LOCATION: ${{ secrets.GCP_LOCATION }} GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} VOYAGE_API_KEY: ${{ secrets.VOYAGE_API_KEY }} diff --git a/docs/api/vectorizer.rst b/docs/api/vectorizer.rst index e7167efc..adfd2cd2 100644 --- a/docs/api/vectorizer.rst +++ b/docs/api/vectorizer.rst @@ -8,7 +8,8 @@ Vectorizers compatibility: - ``VoyageAITextVectorizer`` → Use ``VoyageAIVectorizer`` instead - - ``VertexAITextVectorizer`` → Use ``VertexAIVectorizer`` instead + - ``VertexAITextVectorizer`` → Use ``GoogleGenAIVectorizer`` instead + (``VertexAIVectorizer`` is itself deprecated; see below) - ``BedrockTextVectorizer`` → Use ``BedrockVectorizer`` instead - ``CustomTextVectorizer`` → Use ``CustomVectorizer`` instead @@ -59,15 +60,31 @@ VertexAIVectorizer .. currentmodule:: redisvl.utils.vectorize.vertexai .. note:: - For backwards compatibility, an alias ``VertexAITextVectorizer`` is available - in the ``redisvl.utils.vectorize.text`` module. This alias is deprecated - as of version 0.13.0 and will be removed in a future major release. + ``VertexAIVectorizer`` is **deprecated**. It uses Google's Vertex AI + model-garden SDK, which Google has deprecated with a scheduled removal. Use + :class:`~redisvl.utils.vectorize.googlegenai.GoogleGenAIVectorizer` for text + embeddings on the supported ``google-genai`` SDK. The alias + ``VertexAITextVectorizer`` (in ``redisvl.utils.vectorize.text``) is likewise + deprecated. Multimodal (image/video) migration is tracked in + `issue #620 `_. .. autoclass:: VertexAIVectorizer :show-inheritance: :members: +GoogleGenAIVectorizer +===================== + +.. _googlegenaivectorizer_api: + +.. currentmodule:: redisvl.utils.vectorize.googlegenai + +.. autoclass:: GoogleGenAIVectorizer + :show-inheritance: + :members: + + CohereTextVectorizer ==================== diff --git a/docs/concepts/utilities.md b/docs/concepts/utilities.md index 77ae9ed7..44282f6f 100644 --- a/docs/concepts/utilities.md +++ b/docs/concepts/utilities.md @@ -35,7 +35,7 @@ Vectorizers handle batching internally, breaking large batches into provider-app ### Supported Providers -RedisVL includes vectorizers for OpenAI, Azure OpenAI, Cohere, HuggingFace (local), Mistral, Google Vertex AI, AWS Bedrock, VoyageAI, and others. See the {doc}`/api/vectorizer` for the complete list. You can also create custom vectorizers that wrap any embedding function. +RedisVL includes vectorizers for OpenAI, Azure OpenAI, Cohere, HuggingFace (local), Mistral, Google (via `google-genai`, covering both Vertex AI and the Gemini Developer API), AWS Bedrock, VoyageAI, and others. See the {doc}`/api/vectorizer` for the complete list. You can also create custom vectorizers that wrap any embedding function. ## Rerankers diff --git a/docs/user_guide/04_vectorizers.ipynb b/docs/user_guide/04_vectorizers.ipynb index 6ea856ae..bd980174 100644 --- a/docs/user_guide/04_vectorizers.ipynb +++ b/docs/user_guide/04_vectorizers.ipynb @@ -6,7 +6,7 @@ "source": [ "# Create Embeddings with Vectorizers\n", "\n", - "This guide demonstrates how to create embeddings using RedisVL's built-in text vectorizers. RedisVL supports multiple embedding providers: OpenAI, HuggingFace, Ollama, Vertex AI, Cohere, Mistral AI, Amazon Bedrock, VoyageAI, and custom vectorizers.\n", + "This guide demonstrates how to create embeddings using RedisVL's built-in text vectorizers. RedisVL supports multiple embedding providers: OpenAI, HuggingFace, Ollama, Google (Vertex AI and the Gemini Developer API), Cohere, Mistral AI, Amazon Bedrock, VoyageAI, and custom vectorizers.\n", "\n", "## Prerequisites\n", "\n", @@ -360,6 +360,8 @@ "cell_type": "markdown", "metadata": {}, "source": [ + "> **⚠️ Deprecated.** `VertexAIVectorizer` uses Google's Vertex AI model-garden SDK, which Google has deprecated with a scheduled removal. For text embeddings use the **Google Gen AI** vectorizer shown in the next section (`GoogleGenAIVectorizer`) on the supported `google-genai` SDK. Multimodal (image/video) migration is tracked in [issue #620](https://github.com/redis/redis-vl-python/issues/620).\n", + "\n", "### VertexAI\n", "\n", "[VertexAI](https://cloud.google.com/vertex-ai/docs/generative-ai/embeddings/get-text-embeddings) is GCP's fully-featured AI platform including a number of pretrained LLMs. RedisVL supports using VertexAI to create embeddings from these models. To use VertexAI, you will first need to install the ``google-cloud-aiplatform`` library.\n", @@ -402,6 +404,64 @@ "test[:10]" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Google Gen AI\n", + "\n", + "The `GoogleGenAIVectorizer` uses Google's [`google-genai`](https://pypi.org/project/google-genai/) SDK — the supported replacement for the deprecated Vertex AI model-garden SDK. It reaches **both** Google embedding backends from a single client:\n", + "\n", + "- **Vertex AI / Gemini Enterprise** — GCP project auth (`project_id` + `location`, credentials via ADC).\n", + "- **Gemini Developer API** — a single `api_key`.\n", + "\n", + "Install it with `pip install redisvl[google-genai]`.\n", + "\n", + "**Migrating from `VertexAIVectorizer`:**\n", + "\n", + "| | `VertexAIVectorizer` (deprecated) | `GoogleGenAIVectorizer` |\n", + "|---|---|---|\n", + "| SDK | `google-cloud-aiplatform` (deprecated) | `google-genai` (supported) |\n", + "| Default model | `textembedding-gecko` (768 dims) | `gemini-embedding-001` (3072 dims) |\n", + "| Backends | Vertex AI only | Vertex AI **and** Gemini Developer API |\n", + "| Async | no | yes (`aembed`, `aembed_many`) |\n", + "\n", + "Because the default model and dimensions differ, embeddings are **not** interchangeable — reindex when you switch. When you request a reduced `output_dimensionality`, the vectorizer L2-normalizes the result so it stays valid for Redis COSINE / inner-product search.\n", + "\n", + "**Set one of the following:**\n", + "\n", + "```\n", + "# Gemini Developer API\n", + "GEMINI_API_KEY=\n", + "\n", + "# or Vertex AI\n", + "GOOGLE_CLOUD_PROJECT=\n", + "GOOGLE_CLOUD_LOCATION=\n", + "GOOGLE_APPLICATION_CREDENTIALS=\n", + "```\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# NBVAL_SKIP (docs example; not executed in CI so notebook validation makes no API calls)\n", + "from redisvl.utils.vectorize import GoogleGenAIVectorizer\n", + "\n", + "# Auto-detects the backend: GEMINI_API_KEY -> Gemini, else GCP project/location -> Vertex AI.\n", + "# Guarded so this notebook still runs when Google credentials aren't configured.\n", + "try:\n", + " genai_vectorizer = GoogleGenAIVectorizer(model=\"gemini-embedding-001\")\n", + " print(f\"backend={genai_vectorizer.backend}, dims={genai_vectorizer.dims}\")\n", + " genai_test = genai_vectorizer.embed(\"This is a test sentence.\")\n", + " print(genai_test[:10])\n", + "except (ImportError, ValueError) as e:\n", + " print(f\"Skipping GoogleGenAIVectorizer demo: {e}\")\n", + " genai_vectorizer = None\n" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/docs/user_guide/installation.md b/docs/user_guide/installation.md index 18002ba7..ab258ba1 100644 --- a/docs/user_guide/installation.md +++ b/docs/user_guide/installation.md @@ -27,7 +27,8 @@ $ pip install redisvl[cohere] # Cohere embeddings and reranking $ pip install redisvl[mistralai] # Mistral AI embeddings $ pip install redisvl[voyageai] # Voyage AI embeddings and reranking $ pip install redisvl[sentence-transformers] # HuggingFace local embeddings -$ pip install redisvl[vertexai] # Google Vertex AI embeddings +$ pip install redisvl[google-genai] # Google embeddings (Vertex AI + Gemini API) +$ pip install redisvl[vertexai] # Google Vertex AI embeddings (legacy; deprecated, multimodal only) $ pip install redisvl[bedrock] # AWS Bedrock embeddings # Other optional features diff --git a/pyproject.toml b/pyproject.toml index b47ba2b8..78545f76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,10 +44,19 @@ cohere = ["cohere>=4.44"] voyageai = ["voyageai>=0.2.2"] sentence-transformers = ["sentence-transformers>=3.4.0,<4"] langcache = ["langcache>=0.11.0"] +# Legacy Google backend for the deprecated VertexAIVectorizer. The <2.0.0 cap is +# intentional: Google's model-garden modules (vertexai.language_models / +# vertexai.vision_models) that this vectorizer imports are deprecated with a +# scheduled removal. They still ship in the 1.x line (latest 1.162.0); the 2.0.0 +# release that drops them is yanked on PyPI. Keep the cap so the deprecated class +# (and its multimodal path, which has no google-genai replacement yet) keeps +# importing. New text embeddings should use the `google-genai` extra below. vertexai = [ "google-cloud-aiplatform>=1.26,<2.0.0", "protobuf>=5.28.0,<6.0.0", ] +# Modern Google backend (Vertex AI + Gemini Developer API) for GoogleGenAIVectorizer. +google-genai = ["google-genai>=1.0.0"] bedrock = [ "boto3>=1.36.0,<2", "urllib3<2.2.0", @@ -67,6 +76,7 @@ all = [ "langcache>=0.11.0", "google-cloud-aiplatform>=1.26,<2.0.0", "protobuf>=5.28.0,<6.0.0", + "google-genai>=1.0.0", "boto3>=1.36.0,<2", "urllib3<2.2.0", "pillow>=11.3.0", diff --git a/redisvl/utils/utils.py b/redisvl/utils/utils.py index 1bdbd58e..85f74397 100644 --- a/redisvl/utils/utils.py +++ b/redisvl/utils/utils.py @@ -203,7 +203,15 @@ def decorator(cls): @wraps(original_init) def new_init(self, *args, **kwargs): - warn(warning_message, category=DeprecationWarning, stacklevel=2) + # Emit only once per instance. When a deprecated subclass wraps a + # deprecated parent, both __init__ wrappers run via super().__init__; + # the sentinel keeps that to a single warning. + if not getattr(self, "_deprecation_warned", False): + warn(warning_message, category=DeprecationWarning, stacklevel=2) + try: + object.__setattr__(self, "_deprecation_warned", True) + except Exception: + pass original_init(self, *args, **kwargs) cls.__init__ = new_init diff --git a/redisvl/utils/vectorize/__init__.py b/redisvl/utils/vectorize/__init__.py index fd00b4e0..93c6b6a4 100644 --- a/redisvl/utils/vectorize/__init__.py +++ b/redisvl/utils/vectorize/__init__.py @@ -2,6 +2,7 @@ from redisvl.utils.vectorize.base import BaseVectorizer, Vectorizers from redisvl.utils.vectorize.bedrock import BedrockVectorizer from redisvl.utils.vectorize.custom import CustomVectorizer +from redisvl.utils.vectorize.googlegenai import GoogleGenAIVectorizer from redisvl.utils.vectorize.text.azureopenai import AzureOpenAITextVectorizer from redisvl.utils.vectorize.text.bedrock import BedrockTextVectorizer from redisvl.utils.vectorize.text.cohere import CohereTextVectorizer @@ -20,6 +21,7 @@ "CohereTextVectorizer", "HFTextVectorizer", "OpenAITextVectorizer", + "GoogleGenAIVectorizer", "VertexAIVectorizer", "VertexAITextVectorizer", "AzureOpenAITextVectorizer", @@ -61,6 +63,8 @@ def vectorizer_from_dict( return OllamaTextVectorizer(**args) elif vectorizer_type == Vectorizers.vertexai: return VertexAIVectorizer(**args) + elif vectorizer_type == Vectorizers.google_genai: + return GoogleGenAIVectorizer(**args) elif vectorizer_type == Vectorizers.voyageai: return VoyageAIVectorizer(**args) else: diff --git a/redisvl/utils/vectorize/base.py b/redisvl/utils/vectorize/base.py index 694eca52..ca4d72be 100644 --- a/redisvl/utils/vectorize/base.py +++ b/redisvl/utils/vectorize/base.py @@ -28,6 +28,7 @@ class Vectorizers(Enum): mistral = "mistral" ollama = "ollama" vertexai = "vertexai" + google_genai = "google_genai" hf = "hf" voyageai = "voyageai" diff --git a/redisvl/utils/vectorize/googlegenai.py b/redisvl/utils/vectorize/googlegenai.py new file mode 100644 index 00000000..73b66930 --- /dev/null +++ b/redisvl/utils/vectorize/googlegenai.py @@ -0,0 +1,384 @@ +import os +from typing import TYPE_CHECKING, Any + +from pydantic import ConfigDict +from tenacity import ( + retry, + retry_if_not_exception_type, + stop_after_attempt, + wait_random_exponential, +) + +from redisvl.utils.vectorize.base import BaseVectorizer + +if TYPE_CHECKING: + from redisvl.extensions.cache.embeddings.embeddings import EmbeddingsCache + + +class GoogleGenAIVectorizer(BaseVectorizer): + """The GoogleGenAIVectorizer creates embeddings with Google's `google-genai` + SDK, the supported replacement for the deprecated Vertex AI model-garden SDK + used by :class:`VertexAIVectorizer`. + + One client, two backends. `google-genai` reaches both of Google's embedding + backends; this vectorizer auto-selects one from your config/environment (or an + explicit override): + + - **Vertex AI / Gemini Enterprise** (GCP project auth) — for existing Vertex users. + - **Gemini Developer API** (a single API key) — the simplest way to get started. + + Credentials are resolved in this order (explicit beats ambient): + + 1. Explicit override — ``api_config={"backend": "vertex" | "gemini"}``. + 2. Explicit creds in ``api_config`` — ``api_key`` selects Gemini; ``project_id`` + (+ ``location``) selects Vertex. + 3. Environment — a project (``GOOGLE_CLOUD_PROJECT`` | ``GCP_PROJECT_ID`` with + ``GOOGLE_CLOUD_LOCATION`` | ``GCP_LOCATION`` and Application Default + Credentials via ``GOOGLE_APPLICATION_CREDENTIALS``) selects Vertex; otherwise + ``GEMINI_API_KEY`` | ``GOOGLE_API_KEY`` selects Gemini. If both a project and a + key are present, Vertex wins. + + Install the client with ``pip install redisvl[google-genai]``. + + .. note:: + The default model ``gemini-embedding-001`` returns **3072-dimensional** + vectors (≈4× the width of the legacy ``textembedding-gecko``, so ≈4× the + index memory). A reduced ``output_dimensionality`` returns shorter vectors that + Google does **not** re-normalize; this is invisible under the COSINE metric + (scale-invariant) but matters for inner-product / L2 — normalize yourself if your + index needs it. Embeddings from different models (or dimensions) are not + interchangeable — reindex when you change them. + + .. code-block:: python + + # Vertex AI backend + from redisvl.utils.vectorize import GoogleGenAIVectorizer + + vectorizer = GoogleGenAIVectorizer( + model="gemini-embedding-001", + api_config={ + "project_id": "your-gcp-project", # or set GOOGLE_CLOUD_PROJECT + "location": "us-central1", # or set GOOGLE_CLOUD_LOCATION + }, + ) + embedding = vectorizer.embed("Hello, world!") + + # Gemini Developer API backend + vectorizer = GoogleGenAIVectorizer( + model="gemini-embedding-001", + api_config={"api_key": "your-gemini-api-key"}, # or set GEMINI_API_KEY + ) + + # Reduced dimensions (Matryoshka) + caching + from redisvl.extensions.cache.embeddings import EmbeddingsCache + + vectorizer = GoogleGenAIVectorizer( + model="gemini-embedding-001", + output_dimensionality=768, + cache=EmbeddingsCache(name="genai_embeddings_cache"), + ) + + # Asynchronous batch embedding + embeddings = await vectorizer.aembed_many( + ["Hello, world!", "How are you?"], batch_size=2 + ) + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + def __init__( + self, + model: str = "gemini-embedding-001", + api_config: dict[str, Any] | None = None, + dtype: str = "float32", + cache: "EmbeddingsCache | None" = None, + task_type: str | None = None, + output_dimensionality: int | None = None, + **kwargs, + ): + """Initialize the Google GenAI vectorizer. + + Args: + model (str): The embedding model to use. Defaults to + 'gemini-embedding-001', which works on both backends. + api_config (Optional[Dict]): Auth/client configuration. Recognized keys: + ``backend`` ("vertex"|"gemini"), ``project_id``, ``location``, + ``credentials`` (Vertex), and ``api_key`` (Gemini). Model-behavior + options are the ``task_type``/``output_dimensionality`` arguments + below, not ``api_config`` keys. Defaults to None. + dtype (str): The default datatype to use when embedding text as byte + arrays. Used when setting ``as_buffer=True``. Defaults to 'float32'. + cache (Optional[EmbeddingsCache]): Optional cache for repeated inputs. + task_type (Optional[str]): Default embedding task type (e.g. + 'RETRIEVAL_DOCUMENT', 'RETRIEVAL_QUERY'). Overridable per call. + output_dimensionality (Optional[int]): Request shorter (Matryoshka) + embeddings of this width; sets ``dims`` accordingly. Fixed for the + vectorizer's lifetime (cannot be overridden per call). Note Google does + not re-normalize reduced vectors. + **kwargs: Additional arguments forwarded to ``google.genai.Client`` + (e.g. ``http_options``). + + Raises: + ImportError: If the google-genai library is not installed. + ValueError: If a backend cannot be resolved, or an invalid dtype is given. + """ + super().__init__(model=model, dtype=dtype, cache=cache) + self._setup(api_config, task_type, output_dimensionality, **kwargs) + + def _setup( + self, + api_config: dict[str, Any] | None, + task_type: str | None, + output_dimensionality: int | None, + **kwargs, + ): + """Build the default embed config, initialize the client, and set dims.""" + # Default embed config is set BEFORE _set_model_dims so the dimension-probe + # embed uses the same config (e.g. output_dimensionality) as real calls. + self._embed_config: dict[str, Any] = {} + if task_type is not None: + self._embed_config["task_type"] = task_type + if output_dimensionality is not None: + self._embed_config["output_dimensionality"] = output_dimensionality + + self._initialize_client(api_config, **kwargs) + self.dims = self._set_model_dims() + + def _initialize_client(self, api_config: dict[str, Any] | None, **kwargs): + """Resolve the backend and construct a single google-genai client. + + Raises: + ImportError: If the google-genai library is not installed. + ValueError: If no backend can be resolved from config or environment. + """ + # Copy so we never mutate the caller's dict. + api_config = dict(api_config or {}) + + try: + from google import genai + from google.genai.types import EmbedContentConfig + except ImportError: + raise ImportError( + "GoogleGenAIVectorizer requires the google-genai library. " + "Please install with `pip install redisvl[google-genai]`" + ) + + self._config_cls = EmbedContentConfig + backend = self._resolve_backend(api_config) + + if backend == "vertex": + project = ( + api_config.get("project_id") + or os.getenv("GOOGLE_CLOUD_PROJECT") + or os.getenv("GCP_PROJECT_ID") + ) + location = ( + api_config.get("location") + or os.getenv("GOOGLE_CLOUD_LOCATION") + or os.getenv("GCP_LOCATION") + ) + if not location: + raise ValueError( + "Vertex AI backend selected but no location was provided. " + "Set api_config['location'] or the GOOGLE_CLOUD_LOCATION " + "(or GCP_LOCATION) environment variable." + ) + self._client = genai.Client( + vertexai=True, + project=project, + location=location, + credentials=api_config.get("credentials"), + **kwargs, + ) + else: + api_key = ( + api_config.get("api_key") + or os.getenv("GEMINI_API_KEY") + or os.getenv("GOOGLE_API_KEY") + ) + self._client = genai.Client(api_key=api_key, **kwargs) + + self._backend = backend + + @staticmethod + def _resolve_backend(api_config: dict[str, Any]) -> str: + """Return 'vertex' or 'gemini' using explicit-beats-ambient precedence.""" + # 1. Explicit override. + explicit = api_config.get("backend") + if explicit is not None: + if explicit not in ("vertex", "gemini"): + raise ValueError( + f"Invalid api_config['backend']: {explicit!r}. " + "Must be 'vertex' or 'gemini'." + ) + return explicit + if "vertexai" in api_config: + return "vertex" if api_config["vertexai"] else "gemini" + + # 2. Explicit credentials in api_config. + if api_config.get("api_key"): + return "gemini" + if api_config.get("project_id"): + return "vertex" + + # 3. Ambient environment (project beats key). + if os.getenv("GOOGLE_CLOUD_PROJECT") or os.getenv("GCP_PROJECT_ID"): + return "vertex" + if os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY"): + return "gemini" + + raise ValueError( + "Could not resolve a Google backend. Provide Vertex AI config " + "(api_config={'project_id': ..., 'location': ...} or set " + "GOOGLE_CLOUD_PROJECT/GOOGLE_CLOUD_LOCATION [or GCP_PROJECT_ID/GCP_LOCATION] " + "with Application Default Credentials), or a Gemini Developer API key " + "(api_config={'api_key': ...} or set GEMINI_API_KEY/GOOGLE_API_KEY)." + ) + + @property + def backend(self) -> str: + """The resolved backend: 'vertex' or 'gemini'.""" + return self._backend + + def _set_model_dims(self) -> int: + """Determine embedding dimensionality via a single probe embed.""" + try: + embedding = self._embed("dimension check") + return len(embedding) + except (KeyError, IndexError, AttributeError) as e: + raise ValueError(f"Unexpected response from the Google GenAI API: {e}") + except Exception as e: # pylint: disable=broad-except + # Avoid interpolating provider/transport errors, which can embed + # credentials, into the message. Chain the original for debugging. + raise ValueError( + "Error setting embedding model dimensions with the Google GenAI API. " + "Verify the model name, backend selection, and credentials." + ) from e + + def _build_config(self, extra: dict[str, Any]) -> Any: + """Merge stored config defaults with per-call kwargs into an EmbedContentConfig. + + ``output_dimensionality`` is fixed at construction (it determines ``self.dims``), + so it cannot be overridden per call — that would desync returned embeddings from + the index's vector width. Other fields (e.g. ``task_type``) may vary per call. + """ + if "output_dimensionality" in extra: + raise TypeError( + "output_dimensionality cannot be overridden per call; set it on the " + "GoogleGenAIVectorizer(...) constructor instead (it determines dims)." + ) + merged = {**self._embed_config, **extra} + return self._config_cls(**merged) if merged else None + + @staticmethod + def _embeddings_from_response(response: Any, expected: int) -> list: + """Return the response embeddings, guarding against None / count mismatch.""" + embeddings = response.embeddings + if not embeddings or len(embeddings) != expected: + count = 0 if not embeddings else len(embeddings) + raise ValueError( + f"Google GenAI API returned {count} embedding(s) for {expected} " + "input(s)." + ) + return embeddings + + @staticmethod + def _values(embedding: Any) -> list[float]: + """Return an embedding's raw values, guarding against a null payload.""" + if embedding.values is None: + raise ValueError("No embedding returned from the Google GenAI API.") + return list(embedding.values) + + @staticmethod + def _validate_many(contents: list[str]) -> None: + """Validate batch input without masking the validation error.""" + if not isinstance(contents, list): + raise TypeError( + f"Input contents must be a list of strings to embed, got {type(contents)}" + ) + if not all(isinstance(c, str) for c in contents): + raise TypeError("Input contents must be a list of strings to embed.") + + @retry( + wait=wait_random_exponential(min=1, max=60), + stop=stop_after_attempt(6), + retry=retry_if_not_exception_type(TypeError), + reraise=True, + ) + def _embed(self, content: str, **kwargs) -> list[float]: + """Generate a vector embedding for a single string.""" + if not isinstance(content, str): + raise TypeError( + f"Input content must be a string to embed, got {type(content)}" + ) + config = self._build_config(kwargs) + response = self._client.models.embed_content( + model=self.model, contents=content, config=config + ) + embeddings = self._embeddings_from_response(response, 1) + return self._values(embeddings[0]) + + @retry( + wait=wait_random_exponential(min=1, max=60), + stop=stop_after_attempt(6), + retry=retry_if_not_exception_type(TypeError), + reraise=True, + ) + def _embed_many( + self, contents: list[str], batch_size: int = 10, **kwargs + ) -> list[list[float]]: + """Generate vector embeddings for a batch of strings.""" + self._validate_many(contents) + config = self._build_config(kwargs) + embeddings: list[list[float]] = [] + for batch in self.batchify(contents, batch_size): + response = self._client.models.embed_content( + model=self.model, contents=batch, config=config + ) + batch_embeddings = self._embeddings_from_response(response, len(batch)) + embeddings.extend(self._values(e) for e in batch_embeddings) + return embeddings + + @retry( + wait=wait_random_exponential(min=1, max=60), + stop=stop_after_attempt(6), + retry=retry_if_not_exception_type(TypeError), + reraise=True, + ) + async def _aembed(self, content: str, **kwargs) -> list[float]: + """Asynchronously generate a vector embedding for a single string.""" + if not isinstance(content, str): + raise TypeError( + f"Input content must be a string to embed, got {type(content)}" + ) + config = self._build_config(kwargs) + response = await self._client.aio.models.embed_content( + model=self.model, contents=content, config=config + ) + embeddings = self._embeddings_from_response(response, 1) + return self._values(embeddings[0]) + + @retry( + wait=wait_random_exponential(min=1, max=60), + stop=stop_after_attempt(6), + retry=retry_if_not_exception_type(TypeError), + reraise=True, + ) + async def _aembed_many( + self, contents: list[str], batch_size: int = 10, **kwargs + ) -> list[list[float]]: + """Asynchronously generate vector embeddings for a batch of strings.""" + self._validate_many(contents) + config = self._build_config(kwargs) + embeddings: list[list[float]] = [] + for batch in self.batchify(contents, batch_size): + response = await self._client.aio.models.embed_content( + model=self.model, contents=batch, config=config + ) + batch_embeddings = self._embeddings_from_response(response, len(batch)) + embeddings.extend(self._values(e) for e in batch_embeddings) + return embeddings + + @property + def type(self) -> str: + return "google_genai" diff --git a/redisvl/utils/vectorize/text/vertexai.py b/redisvl/utils/vectorize/text/vertexai.py index 8d2c62a2..8e1a77f5 100644 --- a/redisvl/utils/vectorize/text/vertexai.py +++ b/redisvl/utils/vectorize/text/vertexai.py @@ -5,7 +5,8 @@ @deprecated_class( - name="VertexAITextVectorizer", replacement="Use VertexAIVectorizer instead." + name="VertexAITextVectorizer", + replacement="Use GoogleGenAIVectorizer instead.", ) class VertexAITextVectorizer(VertexAIVectorizer): """A backwards-compatible alias for VertexAIVectorizer.""" diff --git a/redisvl/utils/vectorize/vertexai.py b/redisvl/utils/vectorize/vertexai.py index d8bb2190..6ae47a5c 100644 --- a/redisvl/utils/vectorize/vertexai.py +++ b/redisvl/utils/vectorize/vertexai.py @@ -9,9 +9,21 @@ if TYPE_CHECKING: from redisvl.extensions.cache.embeddings.embeddings import EmbeddingsCache +from redisvl.utils.utils import deprecated_class from redisvl.utils.vectorize.base import BaseVectorizer +_VERTEXAI_DEPRECATION = ( + "Use GoogleGenAIVectorizer for text embeddings — it runs on the supported " + "google-genai SDK. Note the default model changes (textembedding-gecko, 768 " + "dims -> gemini-embedding-001, 3072 dims), so vectors are not interchangeable; " + "reindex when you switch. This class uses Google's Vertex AI model-garden SDK, " + "which is deprecated with a scheduled removal (it still works today but is on a " + "clock). Multimodal (image/video) migration is tracked in " + "https://github.com/redis/redis-vl-python/issues/620." +) + +@deprecated_class(name="VertexAIVectorizer", replacement=_VERTEXAI_DEPRECATION) class VertexAIVectorizer(BaseVectorizer): """The VertexAIVectorizer uses Google's VertexAI embedding model API to create embeddings. diff --git a/tests/integration/test_vectorizers.py b/tests/integration/test_vectorizers.py index 7fcd993e..599c1337 100644 --- a/tests/integration/test_vectorizers.py +++ b/tests/integration/test_vectorizers.py @@ -11,6 +11,7 @@ BedrockVectorizer, CohereTextVectorizer, CustomVectorizer, + GoogleGenAIVectorizer, MistralAITextVectorizer, OpenAITextVectorizer, VertexAIVectorizer, @@ -45,6 +46,7 @@ def embeddings_cache(client): _vectorizer_params = [ OpenAITextVectorizer, VertexAIVectorizer, + GoogleGenAIVectorizer, CohereTextVectorizer, AzureOpenAITextVectorizer, BedrockVectorizer, @@ -64,6 +66,15 @@ def vectorizer(request): return request.param() elif request.param == VertexAIVectorizer: return request.param() + elif request.param == GoogleGenAIVectorizer: + # Prefer the Gemini Developer API key when present so this fixture + # exercises the Gemini backend. The dtype-param tests below construct + # GoogleGenAIVectorizer() with no config, exercising env auto-detect + # (Vertex when GCP creds are set) — so both backends get covered in CI. + api_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") + if api_key: + return request.param(api_config={"api_key": api_key}) + return request.param() elif request.param == CohereTextVectorizer: return request.param() elif request.param == MistralAITextVectorizer: @@ -426,6 +437,7 @@ def bad_return_type(text: str) -> str: MistralAITextVectorizer, OpenAITextVectorizer, VertexAIVectorizer, + GoogleGenAIVectorizer, VoyageAIVectorizer, ] @@ -484,6 +496,7 @@ def test_vectorizer_dtype_assignment(vectorizer_): MistralAITextVectorizer, OpenAITextVectorizer, VertexAIVectorizer, + GoogleGenAIVectorizer, VoyageAIVectorizer, ] diff --git a/tests/unit/test_google_genai_vectorizer.py b/tests/unit/test_google_genai_vectorizer.py new file mode 100644 index 00000000..9f30b80b --- /dev/null +++ b/tests/unit/test_google_genai_vectorizer.py @@ -0,0 +1,443 @@ +import builtins +import sys +import types + +import pytest + +from redisvl.utils.vectorize.base import BaseVectorizer +from redisvl.utils.vectorize.googlegenai import GoogleGenAIVectorizer + +# Environment variables that would otherwise steer backend auto-detection. CI's +# `make test` job exports GCP_* and tests run under xdist, so scrub them and let +# each test set only what it exercises. +_GOOGLE_ENV_VARS = ( + "GCP_PROJECT_ID", + "GCP_LOCATION", + "GOOGLE_CLOUD_PROJECT", + "GOOGLE_CLOUD_LOCATION", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_GENAI_USE_VERTEXAI", +) + + +def _vec(content: str, dim): + """Fake embedding: reduced-dim vectors are intentionally NON-normalized so + tests can verify the vectorizer normalizes them.""" + if dim: + return [float(i + 1) for i in range(dim)] + base = float(len(content)) + return [base, base + 1.0, base + 2.0] + + +class FakeContentEmbedding: + def __init__(self, values): + self.values = values + + +class FakeEmbedResponse: + def __init__(self, embeddings): + self.embeddings = embeddings + + +class FakeEmbedContentConfig: + _allowed = { + "task_type", + "output_dimensionality", + "title", + "mime_type", + "auto_truncate", + } + + def __init__(self, **kwargs): + unknown = set(kwargs) - self._allowed + if unknown: + raise TypeError(f"Unexpected EmbedContentConfig fields: {unknown}") + for field in self._allowed: + setattr(self, field, kwargs.get(field)) + + +def _response_for(contents, config): + dim = getattr(config, "output_dimensionality", None) if config else None + items = [contents] if isinstance(contents, str) else list(contents) + return FakeEmbedResponse([FakeContentEmbedding(_vec(c, dim)) for c in items]) + + +class FakeModels: + def __init__(self, client): + self._client = client + + def embed_content(self, *, model, contents, config=None): + self._client.calls.append( + {"model": model, "contents": contents, "config": config, "async": False} + ) + if self._client.raise_exc is not None: + raise self._client.raise_exc + return _response_for(contents, config) + + +class FakeAioModels: + def __init__(self, client): + self._client = client + + async def embed_content(self, *, model, contents, config=None): + self._client.calls.append( + {"model": model, "contents": contents, "config": config, "async": True} + ) + if self._client.raise_exc is not None: + raise self._client.raise_exc + return _response_for(contents, config) + + +class FakeAio: + def __init__(self, client): + self.models = FakeAioModels(client) + + +class FakeClient: + instances: list = [] + # Set on the class to force a failure during __init__'s dimension probe. + raise_exc_on_init = None + + def __init__(self, **kwargs): + self.kwargs = kwargs + self.calls: list = [] + self.raise_exc = self.__class__.raise_exc_on_init + self.models = FakeModels(self) + self.aio = FakeAio(self) + FakeClient.instances.append(self) + + +@pytest.fixture(autouse=True) +def _scrub_google_env(monkeypatch): + for var in _GOOGLE_ENV_VARS: + monkeypatch.delenv(var, raising=False) + + +@pytest.fixture +def fake_genai(monkeypatch): + FakeClient.instances = [] + FakeClient.raise_exc_on_init = None + + genai_mod = types.ModuleType("google.genai") + types_mod = types.ModuleType("google.genai.types") + types_mod.EmbedContentConfig = FakeEmbedContentConfig + genai_mod.Client = FakeClient + genai_mod.types = types_mod + + import google + + monkeypatch.setattr(google, "genai", genai_mod, raising=False) + monkeypatch.setitem(sys.modules, "google.genai", genai_mod) + monkeypatch.setitem(sys.modules, "google.genai.types", types_mod) + return genai_mod + + +# --------------------------------------------------------------------------- # +# Backend resolution # +# --------------------------------------------------------------------------- # + + +def test_init_vertex_backend_from_api_config(fake_genai): + vectorizer = GoogleGenAIVectorizer( + api_config={"project_id": "proj", "location": "us-central1"} + ) + client = FakeClient.instances[0] + assert vectorizer.backend == "vertex" + assert vectorizer.type == "google_genai" + assert vectorizer.dims == 3 + assert client.kwargs["vertexai"] is True + assert client.kwargs["project"] == "proj" + assert client.kwargs["location"] == "us-central1" + + +def test_init_gemini_backend_from_api_config(fake_genai): + vectorizer = GoogleGenAIVectorizer(api_config={"api_key": "secret-key"}) + client = FakeClient.instances[0] + assert vectorizer.backend == "gemini" + assert client.kwargs.get("api_key") == "secret-key" + assert "vertexai" not in client.kwargs + + +def test_explicit_backend_override_beats_creds(fake_genai): + # project present, but explicitly force gemini + vectorizer = GoogleGenAIVectorizer( + api_config={"backend": "gemini", "project_id": "proj", "api_key": "k"} + ) + assert vectorizer.backend == "gemini" + + +def test_explicit_api_key_beats_ambient_project(fake_genai, monkeypatch): + monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "amb-proj") + monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-central1") + vectorizer = GoogleGenAIVectorizer(api_config={"api_key": "k"}) + assert vectorizer.backend == "gemini" + + +def test_env_project_selects_vertex(fake_genai, monkeypatch): + monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "p") + monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-central1") + vectorizer = GoogleGenAIVectorizer() + assert vectorizer.backend == "vertex" + assert FakeClient.instances[0].kwargs["project"] == "p" + + +def test_env_key_selects_gemini(fake_genai, monkeypatch): + monkeypatch.setenv("GEMINI_API_KEY", "k") + vectorizer = GoogleGenAIVectorizer() + assert vectorizer.backend == "gemini" + + +def test_both_env_creds_vertex_wins(fake_genai, monkeypatch): + monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "p") + monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-central1") + monkeypatch.setenv("GEMINI_API_KEY", "k") + vectorizer = GoogleGenAIVectorizer() + assert vectorizer.backend == "vertex" + + +def test_legacy_gcp_env_vars_supported(fake_genai, monkeypatch): + monkeypatch.setenv("GCP_PROJECT_ID", "p") + monkeypatch.setenv("GCP_LOCATION", "us-central1") + vectorizer = GoogleGenAIVectorizer() + assert vectorizer.backend == "vertex" + assert FakeClient.instances[0].kwargs["location"] == "us-central1" + + +def test_vertex_missing_location_raises(fake_genai): + with pytest.raises(ValueError, match="location"): + GoogleGenAIVectorizer(api_config={"project_id": "p"}) + + +def test_no_credentials_raises(fake_genai): + with pytest.raises(ValueError, match="Could not resolve a Google backend"): + GoogleGenAIVectorizer() + + +def test_invalid_backend_value_raises(fake_genai): + with pytest.raises(ValueError, match="Must be 'vertex' or 'gemini'"): + GoogleGenAIVectorizer(api_config={"backend": "bogus"}) + + +def test_api_config_not_mutated(fake_genai): + api_config = {"api_key": "k"} + GoogleGenAIVectorizer(api_config=api_config) + assert api_config == {"api_key": "k"} + + +# --------------------------------------------------------------------------- # +# Embedding # +# --------------------------------------------------------------------------- # + + +def test_embed_forwards_model_and_returns_values(fake_genai): + vectorizer = GoogleGenAIVectorizer( + model="gemini-embedding-001", api_config={"api_key": "k"} + ) + result = vectorizer.embed("hello world") + client = FakeClient.instances[0] + assert result == _vec("hello world", None) + assert client.calls[-1]["model"] == "gemini-embedding-001" + assert client.calls[-1]["contents"] == "hello world" + + +def test_embed_many_batches_and_preserves_order(fake_genai): + vectorizer = GoogleGenAIVectorizer(api_config={"api_key": "k"}) + result = vectorizer.embed_many(["a", "bb", "ccc", "dddd"], batch_size=2) + assert result == [ + _vec("a", None), + _vec("bb", None), + _vec("ccc", None), + _vec("dddd", None), + ] + client = FakeClient.instances[0] + # calls[0] is the dimension probe; the rest are the batches. + assert [c["contents"] for c in client.calls[1:]] == [["a", "bb"], ["ccc", "dddd"]] + + +def test_embed_many_empty_makes_no_api_calls(fake_genai): + vectorizer = GoogleGenAIVectorizer(api_config={"api_key": "k"}) + client = FakeClient.instances[0] + calls_after_init = len(client.calls) + assert vectorizer.embed_many([]) == [] + assert len(client.calls) == calls_after_init + + +@pytest.mark.asyncio +async def test_aembed_uses_async_client(fake_genai): + vectorizer = GoogleGenAIVectorizer(api_config={"api_key": "k"}) + result = await vectorizer.aembed("hello async") + client = FakeClient.instances[0] + assert result == _vec("hello async", None) + assert client.calls[-1]["async"] is True + + +@pytest.mark.asyncio +async def test_aembed_many_uses_async_client(fake_genai): + vectorizer = GoogleGenAIVectorizer(api_config={"api_key": "k"}) + result = await vectorizer.aembed_many(["a", "bb", "ccc"], batch_size=2) + assert result == [_vec("a", None), _vec("bb", None), _vec("ccc", None)] + client = FakeClient.instances[0] + assert [c["contents"] for c in client.calls if c["async"]] == [ + ["a", "bb"], + ["ccc"], + ] + + +# --------------------------------------------------------------------------- # +# Dimensions, normalization, config passthrough # +# --------------------------------------------------------------------------- # + + +def test_output_dimensionality_sets_dims(fake_genai): + vectorizer = GoogleGenAIVectorizer( + api_config={"api_key": "k"}, output_dimensionality=6 + ) + assert vectorizer.dims == 6 + result = vectorizer.embed("hello") + # Raw provider values are returned as-is (no normalization). + assert result == _vec("hello", 6) + + +def test_per_call_output_dimensionality_is_rejected(fake_genai): + # output_dimensionality determines dims, so it is fixed at construction and must + # not be overridable per call (that would desync from the index vector width). + vectorizer = GoogleGenAIVectorizer(api_config={"api_key": "k"}) + with pytest.raises(TypeError, match="output_dimensionality"): + vectorizer.embed("hello", output_dimensionality=5) + + +def test_task_type_passthrough_per_call(fake_genai): + vectorizer = GoogleGenAIVectorizer(api_config={"api_key": "k"}) + vectorizer.embed("hello", task_type="RETRIEVAL_QUERY") + config = FakeClient.instances[0].calls[-1]["config"] + assert config.task_type == "RETRIEVAL_QUERY" + + +def test_task_type_default_applied_to_every_call(fake_genai): + vectorizer = GoogleGenAIVectorizer( + api_config={"api_key": "k"}, task_type="RETRIEVAL_DOCUMENT" + ) + vectorizer.embed("hello") + config = FakeClient.instances[0].calls[-1]["config"] + assert config.task_type == "RETRIEVAL_DOCUMENT" + + +# --------------------------------------------------------------------------- # +# Input validation and errors # +# --------------------------------------------------------------------------- # + + +def test_rejects_invalid_single_content(fake_genai): + vectorizer = GoogleGenAIVectorizer(api_config={"api_key": "k"}) + with pytest.raises(TypeError): + vectorizer.embed(42) + + +def test_rejects_invalid_many_contents(fake_genai): + vectorizer = GoogleGenAIVectorizer(api_config={"api_key": "k"}) + with pytest.raises(TypeError): + vectorizer.embed_many("not a list") + with pytest.raises(TypeError): + vectorizer.embed_many(["valid", 42]) + + +def test_invalid_dtype_uses_base_validation(fake_genai): + with pytest.raises(ValueError, match="Invalid data type"): + GoogleGenAIVectorizer(api_config={"api_key": "k"}, dtype="float25") + + +def test_missing_dependency_raises_import_error(monkeypatch): + real_import = builtins.__import__ + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "google" and "genai" in (fromlist or ()): + raise ImportError("no genai") + if name == "google.genai": + raise ImportError("no genai") + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.delitem(sys.modules, "google.genai", raising=False) + monkeypatch.delitem(sys.modules, "google.genai.types", raising=False) + monkeypatch.setattr(builtins, "__import__", fake_import) + monkeypatch.setenv("GEMINI_API_KEY", "k") + + with pytest.raises(ImportError, match=r"pip install redisvl\[google-genai\]"): + GoogleGenAIVectorizer() + + +def test_transient_error_is_retried(fake_genai, monkeypatch): + vectorizer = GoogleGenAIVectorizer(api_config={"api_key": "k"}) + monkeypatch.setattr(GoogleGenAIVectorizer._embed.retry, "sleep", lambda _: None) + client = FakeClient.instances[0] + client.raise_exc = RuntimeError("transient 503") + calls_before = len(client.calls) + + with pytest.raises(RuntimeError, match="transient 503"): + vectorizer.embed("hello") + + assert len(client.calls) - calls_before == 6 + + +@pytest.mark.asyncio +async def test_async_transient_error_is_retried(fake_genai, monkeypatch): + async def _no_sleep(_): + return None + + vectorizer = GoogleGenAIVectorizer(api_config={"api_key": "k"}) + monkeypatch.setattr(GoogleGenAIVectorizer._aembed.retry, "sleep", _no_sleep) + client = FakeClient.instances[0] + client.raise_exc = RuntimeError("transient 503") + calls_before = len(client.calls) + + with pytest.raises(RuntimeError, match="transient 503"): + await vectorizer.aembed("hello") + + assert len(client.calls) - calls_before == 6 + + +def test_credentials_never_appear_in_raised_error(fake_genai, monkeypatch): + # Force the dimension probe to fail with an error that echoes the api key. + secret = "super-secret-key-12345" + monkeypatch.setattr( + FakeClient, + "raise_exc_on_init", + RuntimeError(f"HTTP 401 for key={secret}"), + ) + with pytest.raises(ValueError) as exc_info: + GoogleGenAIVectorizer(api_config={"api_key": secret}) + assert secret not in str(exc_info.value) + + +# --------------------------------------------------------------------------- # +# Wiring # +# --------------------------------------------------------------------------- # + + +def test_vectorizer_from_dict_supports_google_genai(fake_genai, monkeypatch): + monkeypatch.setenv("GEMINI_API_KEY", "k") + from redisvl.utils.vectorize import vectorizer_from_dict + + vectorizer = vectorizer_from_dict( + {"type": "google_genai", "model": "gemini-embedding-001", "dtype": "float64"} + ) + assert isinstance(vectorizer, GoogleGenAIVectorizer) + assert vectorizer.model == "gemini-embedding-001" + assert vectorizer.dtype == "float64" + assert vectorizer.backend == "gemini" + + +def test_enum_and_public_export(): + from redisvl.utils.vectorize.base import Vectorizers + + assert Vectorizers("google_genai") == Vectorizers.google_genai + + import redisvl.utils.vectorize as vectorize + + assert vectorize.GoogleGenAIVectorizer is GoogleGenAIVectorizer + + +def test_uses_base_public_batch_embedding_methods(): + assert GoogleGenAIVectorizer.embed_many is BaseVectorizer.embed_many + assert GoogleGenAIVectorizer.aembed_many is BaseVectorizer.aembed_many diff --git a/tests/unit/test_vertexai_deprecation.py b/tests/unit/test_vertexai_deprecation.py new file mode 100644 index 00000000..b2d32052 --- /dev/null +++ b/tests/unit/test_vertexai_deprecation.py @@ -0,0 +1,76 @@ +"""Offline tests for the VertexAIVectorizer deprecation (issue #620). + +These use a fake `vertexai` module so they run without google-cloud-aiplatform +or live GCP credentials. +""" + +import sys +import types +import warnings + +import pytest + + +@pytest.fixture +def fake_vertexai(monkeypatch): + monkeypatch.setenv("GCP_PROJECT_ID", "proj") + monkeypatch.setenv("GCP_LOCATION", "us-central1") + + vertexai_mod = types.ModuleType("vertexai") + + def init(**kwargs): + return None + + vertexai_mod.init = init + + lang_mod = types.ModuleType("vertexai.language_models") + + class _FakeEmbedding: + def __init__(self, values): + self.values = values + + class FakeTextEmbeddingModel: + @classmethod + def from_pretrained(cls, model): + return cls() + + def get_embeddings(self, contents, **kwargs): + items = contents if isinstance(contents, list) else [contents] + return [_FakeEmbedding([0.1, 0.2, 0.3]) for _ in items] + + lang_mod.TextEmbeddingModel = FakeTextEmbeddingModel + vertexai_mod.language_models = lang_mod + + monkeypatch.setitem(sys.modules, "vertexai", vertexai_mod) + monkeypatch.setitem(sys.modules, "vertexai.language_models", lang_mod) + return vertexai_mod + + +def _deprecation_warnings(records): + return [w for w in records if issubclass(w.category, DeprecationWarning)] + + +def test_vertexai_vectorizer_warns_once(fake_vertexai): + from redisvl.utils.vectorize.vertexai import VertexAIVectorizer + + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + VertexAIVectorizer(model="textembedding-gecko") + + dep = _deprecation_warnings(rec) + assert len(dep) == 1 + assert "GoogleGenAIVectorizer" in str(dep[0].message) + + +def test_vertexai_text_vectorizer_warns_once(fake_vertexai): + """The already-deprecated alias subclasses the now-deprecated parent; the + idempotency sentinel keeps that to a single warning.""" + from redisvl.utils.vectorize.text.vertexai import VertexAITextVectorizer + + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + VertexAITextVectorizer(model="textembedding-gecko") + + dep = _deprecation_warnings(rec) + assert len(dep) == 1 + assert "GoogleGenAIVectorizer" in str(dep[0].message) diff --git a/uv.lock b/uv.lock index 20a95a48..b0193608 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10, <3.15" resolution-markers = [ "python_full_version >= '3.14'", @@ -4854,7 +4854,7 @@ wheels = [ [[package]] name = "redisvl" -version = "0.22.0" +version = "0.23.0" source = { editable = "." } dependencies = [ { name = "jsonpath-ng" }, @@ -4874,6 +4874,7 @@ all = [ { name = "cohere" }, { name = "google-cloud-aiplatform", version = "1.148.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "google-cloud-aiplatform", version = "1.154.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "google-genai" }, { name = "langcache" }, { name = "mistralai" }, { name = "ollama" }, @@ -4892,6 +4893,9 @@ bedrock = [ cohere = [ { name = "cohere" }, ] +google-genai = [ + { name = "google-genai" }, +] langcache = [ { name = "langcache" }, ] @@ -4967,6 +4971,8 @@ requires-dist = [ { name = "fastmcp", marker = "extra == 'mcp'", specifier = ">=2.0.0" }, { name = "google-cloud-aiplatform", marker = "extra == 'all'", specifier = ">=1.26,<2.0.0" }, { name = "google-cloud-aiplatform", marker = "extra == 'vertexai'", specifier = ">=1.26,<2.0.0" }, + { name = "google-genai", marker = "extra == 'all'", specifier = ">=1.0.0" }, + { name = "google-genai", marker = "extra == 'google-genai'", specifier = ">=1.0.0" }, { name = "jsonpath-ng", specifier = ">=1.5.0" }, { name = "langcache", marker = "extra == 'all'", specifier = ">=0.11.0" }, { name = "langcache", marker = "extra == 'langcache'", specifier = ">=0.11.0" }, @@ -4997,7 +5003,7 @@ requires-dist = [ { name = "voyageai", marker = "extra == 'all'", specifier = ">=0.2.2" }, { name = "voyageai", marker = "extra == 'voyageai'", specifier = ">=0.2.2" }, ] -provides-extras = ["mcp", "mistralai", "openai", "cohere", "voyageai", "sentence-transformers", "langcache", "vertexai", "bedrock", "pillow", "sql-redis", "all", "ollama"] +provides-extras = ["mcp", "mistralai", "openai", "cohere", "voyageai", "sentence-transformers", "langcache", "vertexai", "google-genai", "bedrock", "pillow", "sql-redis", "all", "ollama"] [package.metadata.requires-dev] dev = [