fleet-memory/hindsight-api-slim/tests/test_gemini_embeddings.py
Volodymyr Prypeshniuk 07de798c3b
feat(google): add support for google embeddings and reranker (#863)
* Add support for google embeddings gemini/vertex and google reranker via vertex search api

* Add reference docs
2026-04-07 09:15:31 +02:00

336 lines
13 KiB
Python

"""
Tests for Google embeddings implementation (Gemini API + Vertex AI).
These tests cover:
1. Initialization (Gemini API key, Vertex AI with ADC/service account)
2. Dimension detection via test embedding
3. Output dimensionality configuration
4. Encode (single text, multiple texts, batching, empty list, uninitialized)
5. Provider name and model name normalization
6. Factory function (create from env, validation errors)
"""
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from hindsight_api.config import (
ENV_EMBEDDINGS_GEMINI_API_KEY,
ENV_EMBEDDINGS_PROVIDER,
HindsightConfig,
)
from hindsight_api.engine.embeddings import GeminiEmbeddings, create_embeddings_from_env
def _make_mock_embedding(values: list[float]) -> MagicMock:
emb = MagicMock()
emb.values = values
return emb
def _make_mock_embed_result(embeddings_data: list[list[float]]) -> MagicMock:
result = MagicMock()
result.embeddings = [_make_mock_embedding(v) for v in embeddings_data]
return result
def _make_mock_genai(embed_result: Any = None) -> MagicMock:
if embed_result is None:
embed_result = _make_mock_embed_result([[0.1] * 768])
mock_genai = MagicMock()
mock_client = MagicMock()
mock_client.models.embed_content = MagicMock(return_value=embed_result)
mock_genai.Client = MagicMock(return_value=mock_client)
return mock_genai
def _make_mock_google_module(mock_genai: MagicMock) -> MagicMock:
mod = MagicMock()
mod.genai = mock_genai
mod.genai.types.EmbedContentConfig = MagicMock(side_effect=lambda **kw: MagicMock(**kw))
return mod
def _patch_google_import(mock_genai: MagicMock):
original_import = __import__
def mock_import(name, *args, **kwargs):
if name == "google":
return _make_mock_google_module(mock_genai)
if name == "google.genai":
return mock_genai
return original_import(name, *args, **kwargs)
return patch("builtins.__import__", side_effect=mock_import)
class TestGeminiEmbeddings:
"""Unit tests for GeminiEmbeddings with mocked google.genai."""
async def test_initialization_api_key_success(self):
"""Test successful Gemini API key initialization."""
mock_genai = _make_mock_genai()
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
with _patch_google_import(mock_genai):
await emb.initialize()
assert emb._client is not None
assert emb.dimension == 768
assert emb.provider_name == "google"
assert emb._is_vertexai is False
mock_genai.Client.return_value.models.embed_content.assert_called_once()
async def test_initialization_vertexai_success(self):
"""Test successful Vertex AI initialization."""
mock_genai = _make_mock_genai()
emb = GeminiEmbeddings(
model="gemini-embedding-001",
vertexai_project_id="test-project",
vertexai_region="us-central1",
)
with _patch_google_import(mock_genai):
await emb.initialize()
assert emb._client is not None
assert emb.dimension == 768
assert emb.provider_name == "google"
assert emb._is_vertexai is True
mock_genai.Client.assert_called_once_with(
vertexai=True,
project="test-project",
location="us-central1",
)
async def test_initialization_missing_api_key(self):
"""Test that missing API key raises ValueError when no vertexai_project_id."""
mock_genai = _make_mock_genai()
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key=None)
with _patch_google_import(mock_genai):
with pytest.raises(ValueError, match="requires an API key"):
await emb.initialize()
async def test_initialization_vertexai_missing_project_id(self):
"""Test that Vertex AI mode requires project_id."""
mock_genai = _make_mock_genai()
emb = GeminiEmbeddings(model="gemini-embedding-001", vertexai_project_id="temp")
emb.vertexai_project_id = None # Simulate misconfiguration
with _patch_google_import(mock_genai):
with pytest.raises(ValueError, match="is required for Vertex AI"):
await emb.initialize()
async def test_initialization_idempotent(self):
"""Test that calling initialize() twice is a no-op."""
mock_genai = _make_mock_genai()
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
with _patch_google_import(mock_genai):
await emb.initialize()
first_client = emb._client
await emb.initialize()
assert emb._client is first_client
async def test_dimension_detection_via_test_embedding(self):
"""Test that dimension is detected via a test embedding call."""
test_embed = _make_mock_embed_result([[0.5] * 256])
mock_genai = _make_mock_genai(embed_result=test_embed)
emb = GeminiEmbeddings(model="some-new-model", api_key="test-key")
with _patch_google_import(mock_genai):
await emb.initialize()
assert emb.dimension == 256
async def test_output_dimensionality(self):
"""Test that output_dimensionality is passed via EmbedContentConfig."""
test_embed = _make_mock_embed_result([[0.1] * 256])
mock_genai = _make_mock_genai(embed_result=test_embed)
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key", output_dimensionality=256)
with _patch_google_import(mock_genai):
await emb.initialize()
assert emb.dimension == 256
assert emb._embed_config is not None
call_kwargs = mock_genai.Client.return_value.models.embed_content.call_args
assert "config" in call_kwargs.kwargs
async def test_no_output_dimensionality(self):
"""Test that no EmbedContentConfig is built when output_dimensionality is None."""
mock_genai = _make_mock_genai()
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key", output_dimensionality=None)
with _patch_google_import(mock_genai):
await emb.initialize()
assert emb._embed_config is None
call_kwargs = mock_genai.Client.return_value.models.embed_content.call_args
assert "config" not in call_kwargs.kwargs
def test_auto_detect_vertexai(self):
"""Test that _is_vertexai is auto-detected from vertexai_project_id."""
assert GeminiEmbeddings(model="m", api_key="k")._is_vertexai is False
assert GeminiEmbeddings(model="m", vertexai_project_id="p")._is_vertexai is True
def test_encode_single_text(self):
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
mock_client = MagicMock()
mock_client.models.embed_content = MagicMock(return_value=_make_mock_embed_result([[0.1, 0.2, 0.3]]))
emb._client = mock_client
emb._dimension = 3
assert emb.encode(["hello"]) == [[0.1, 0.2, 0.3]]
def test_encode_multiple_texts(self):
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
mock_client = MagicMock()
mock_client.models.embed_content = MagicMock(
return_value=_make_mock_embed_result([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])
)
emb._client = mock_client
emb._dimension = 2
result = emb.encode(["a", "b", "c"])
assert len(result) == 3
assert result[1] == [0.3, 0.4]
def test_encode_batching(self):
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key", batch_size=2)
mock_client = MagicMock()
mock_client.models.embed_content = MagicMock(
side_effect=[_make_mock_embed_result([[0.1], [0.2]]), _make_mock_embed_result([[0.3]])]
)
emb._client = mock_client
emb._dimension = 1
assert emb.encode(["a", "b", "c"]) == [[0.1], [0.2], [0.3]]
assert mock_client.models.embed_content.call_count == 2
def test_encode_passes_config(self):
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
mock_client = MagicMock()
mock_client.models.embed_content = MagicMock(return_value=_make_mock_embed_result([[0.1, 0.2]]))
emb._client = mock_client
emb._dimension = 2
emb._embed_config = MagicMock()
emb.encode(["hello"])
assert mock_client.models.embed_content.call_args.kwargs["config"] is emb._embed_config
def test_encode_empty_list(self):
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
emb._client = MagicMock()
emb._dimension = 768
assert emb.encode([]) == []
def test_encode_before_initialization(self):
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
with pytest.raises(RuntimeError, match="not initialized"):
emb.encode(["test"])
def test_dimension_before_initialization(self):
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
with pytest.raises(RuntimeError, match="not initialized"):
_ = emb.dimension
def test_provider_name_always_google(self):
assert GeminiEmbeddings(model="m", api_key="k").provider_name == "google"
assert GeminiEmbeddings(model="m", vertexai_project_id="p").provider_name == "google"
def test_vertexai_strips_google_prefix(self):
mock_genai = _make_mock_genai()
emb = GeminiEmbeddings(model="google/gemini-embedding-001", vertexai_project_id="test-project")
emb._init_vertexai(mock_genai)
assert emb.model == "gemini-embedding-001"
def test_default_region(self):
emb = GeminiEmbeddings(model="m", vertexai_project_id="proj")
assert emb.vertexai_region == "us-central1"
def test_custom_region(self):
emb = GeminiEmbeddings(model="m", vertexai_project_id="proj", vertexai_region="europe-west1")
assert emb.vertexai_region == "europe-west1"
class TestGeminiEmbeddingsFactory:
"""Tests for create_embeddings_from_env() with 'google' provider."""
def _make_config(self, **overrides) -> HindsightConfig:
from dataclasses import fields
defaults = {}
for f in fields(HindsightConfig):
if f.type == "str":
defaults[f.name] = ""
elif f.type == "str | None":
defaults[f.name] = None
elif f.type == "int":
defaults[f.name] = 0
elif f.type == "int | None":
defaults[f.name] = None
elif f.type == "float":
defaults[f.name] = 0.0
elif f.type == "float | None":
defaults[f.name] = None
elif f.type == "bool":
defaults[f.name] = False
elif f.type == "list | None":
defaults[f.name] = None
else:
defaults[f.name] = None
defaults["embeddings_provider"] = "google"
defaults["embeddings_gemini_api_key"] = "test-key"
defaults["embeddings_gemini_model"] = "gemini-embedding-001"
defaults["embeddings_gemini_output_dimensionality"] = 768
defaults["embeddings_vertexai_project_id"] = None
defaults["embeddings_vertexai_region"] = None
defaults["embeddings_vertexai_service_account_key"] = None
defaults.update(overrides)
return HindsightConfig(**defaults)
def test_create_with_api_key(self):
config = self._make_config()
with patch("hindsight_api.config.get_config", return_value=config):
emb = create_embeddings_from_env()
assert isinstance(emb, GeminiEmbeddings)
assert emb.provider_name == "google"
assert emb.api_key == "test-key"
assert emb._is_vertexai is False
def test_create_with_vertexai(self):
config = self._make_config(
embeddings_gemini_api_key=None,
embeddings_vertexai_project_id="my-project",
embeddings_vertexai_region="us-east1",
)
with patch("hindsight_api.config.get_config", return_value=config):
emb = create_embeddings_from_env()
assert isinstance(emb, GeminiEmbeddings)
assert emb._is_vertexai is True
assert emb.api_key is None
assert emb.vertexai_project_id == "my-project"
def test_create_missing_all_credentials(self):
config = self._make_config(embeddings_gemini_api_key=None, embeddings_vertexai_project_id=None)
with patch("hindsight_api.config.get_config", return_value=config):
with pytest.raises(ValueError, match="is required"):
create_embeddings_from_env()
def test_vertexai_takes_priority(self):
config = self._make_config(embeddings_gemini_api_key="key", embeddings_vertexai_project_id="proj")
with patch("hindsight_api.config.get_config", return_value=config):
emb = create_embeddings_from_env()
assert emb._is_vertexai is True
assert emb.api_key is None
def test_create_with_custom_dimensionality(self):
config = self._make_config(embeddings_gemini_output_dimensionality=256)
with patch("hindsight_api.config.get_config", return_value=config):
emb = create_embeddings_from_env()
assert emb.output_dimensionality == 256