diff --git a/hindsight-api-slim/hindsight_api/config.py b/hindsight-api-slim/hindsight_api/config.py index 8b6b4da408..db34ddd689 100644 --- a/hindsight-api-slim/hindsight_api/config.py +++ b/hindsight-api-slim/hindsight_api/config.py @@ -566,7 +566,8 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: # ZeroEntropy defaults DEFAULT_EMBEDDINGS_ZEROENTROPY_MODEL = "zembed-1" -DEFAULT_EMBEDDINGS_ZEROENTROPY_BASE_URL = "https://api.zeroentropy.dev" +# Shared between embeddings (zembed-1) and reranker (zerank-*) — the host is the same. +DEFAULT_ZEROENTROPY_BASE_URL = "https://api.zeroentropy.dev" # ZeroEntropy's API default is 2560, but Hindsight defaults to 1280 so the # provider works with pgvector HNSW's 2000-dimension index limit out of the box. DEFAULT_EMBEDDINGS_ZEROENTROPY_DIMENSIONS = 1280 @@ -1261,7 +1262,7 @@ class HindsightConfig: embeddings_openai_dimensions: int | None = None embeddings_zeroentropy_api_key: str | None = None embeddings_zeroentropy_model: str = DEFAULT_EMBEDDINGS_ZEROENTROPY_MODEL - embeddings_zeroentropy_base_url: str = DEFAULT_EMBEDDINGS_ZEROENTROPY_BASE_URL + embeddings_zeroentropy_base_url: str = DEFAULT_ZEROENTROPY_BASE_URL embeddings_zeroentropy_dimensions: int = DEFAULT_EMBEDDINGS_ZEROENTROPY_DIMENSIONS embeddings_zeroentropy_encoding_format: str = DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT embeddings_zeroentropy_batch_size: int = DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE @@ -1468,11 +1469,6 @@ def validate(self) -> None: f"provider: {self.retain_llm_provider or self.llm_provider})" ) - if self.embeddings_provider.lower() == "zeroentropy": - valid_dimensions = frozenset({2560, 1280, 640, 320, 160, 80, 40}) - if self.embeddings_zeroentropy_dimensions not in valid_dimensions: - values = ", ".join(str(dim) for dim in sorted(valid_dimensions, reverse=True)) - raise ValueError(f"{ENV_EMBEDDINGS_ZEROENTROPY_DIMENSIONS} must be one of {values}") # Warn if local ML dependencies are missing when configured. # Don't hard-fail here — the actual ImportError fires at model init time # with a clear message. This early warning catches it before startup proceeds. @@ -1675,7 +1671,7 @@ def from_env(cls) -> "HindsightConfig": ENV_EMBEDDINGS_ZEROENTROPY_MODEL, DEFAULT_EMBEDDINGS_ZEROENTROPY_MODEL ), embeddings_zeroentropy_base_url=os.getenv( - ENV_EMBEDDINGS_ZEROENTROPY_BASE_URL, DEFAULT_EMBEDDINGS_ZEROENTROPY_BASE_URL + ENV_EMBEDDINGS_ZEROENTROPY_BASE_URL, DEFAULT_ZEROENTROPY_BASE_URL ), embeddings_zeroentropy_dimensions=_parse_positive_int( ENV_EMBEDDINGS_ZEROENTROPY_DIMENSIONS, @@ -1684,7 +1680,7 @@ def from_env(cls) -> "HindsightConfig": ), embeddings_zeroentropy_encoding_format=_parse_optional_choice( ENV_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT, - os.getenv(ENV_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT) or DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT, + os.getenv(ENV_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT), frozenset({"float", "base64"}), ) or DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT, diff --git a/hindsight-api-slim/hindsight_api/engine/cross_encoder.py b/hindsight-api-slim/hindsight_api/engine/cross_encoder.py index 405ecb71c2..50e74146a2 100644 --- a/hindsight-api-slim/hindsight_api/engine/cross_encoder.py +++ b/hindsight-api-slim/hindsight_api/engine/cross_encoder.py @@ -38,6 +38,7 @@ DEFAULT_RERANKER_TEI_HTTP_TIMEOUT, DEFAULT_RERANKER_TEI_MAX_CONCURRENT, DEFAULT_RERANKER_ZEROENTROPY_MODEL, + DEFAULT_ZEROENTROPY_BASE_URL, ENV_RERANKER_ALIBABA_API_KEY, ENV_RERANKER_COHERE_API_KEY, ENV_RERANKER_COHERE_MODEL, @@ -773,7 +774,7 @@ class ZeroEntropyCrossEncoder(CrossEncoderModel): See: https://docs.zeroentropy.dev/models """ - DEFAULT_BASE_URL = "https://api.zeroentropy.dev" + DEFAULT_BASE_URL = DEFAULT_ZEROENTROPY_BASE_URL RERANK_PATH = "/v1/models/rerank" def __init__( diff --git a/hindsight-api-slim/hindsight_api/engine/embeddings.py b/hindsight-api-slim/hindsight_api/engine/embeddings.py index a47f15e861..e5d859a45a 100644 --- a/hindsight-api-slim/hindsight_api/engine/embeddings.py +++ b/hindsight-api-slim/hindsight_api/engine/embeddings.py @@ -33,13 +33,13 @@ DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE, DEFAULT_EMBEDDINGS_OPENAI_MODEL, DEFAULT_EMBEDDINGS_PROVIDER, - DEFAULT_EMBEDDINGS_ZEROENTROPY_BASE_URL, DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE, DEFAULT_EMBEDDINGS_ZEROENTROPY_DIMENSIONS, DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT, DEFAULT_EMBEDDINGS_ZEROENTROPY_LATENCY, DEFAULT_EMBEDDINGS_ZEROENTROPY_MODEL, DEFAULT_LITELLM_API_BASE, + DEFAULT_ZEROENTROPY_BASE_URL, ENV_EMBEDDINGS_COHERE_API_KEY, ENV_EMBEDDINGS_GEMINI_API_KEY, ENV_EMBEDDINGS_LOCAL_FORCE_CPU, @@ -79,14 +79,8 @@ class _ZeroEntropyEmbedResult(BaseModel): embedding: list[float] | str -class _ZeroEntropyEmbedUsage(BaseModel): - total_bytes: int | None = None - total_tokens: int | None = None - - class _ZeroEntropyEmbedResponse(BaseModel): results: list[_ZeroEntropyEmbedResult] - usage: _ZeroEntropyEmbedUsage | None = None class Embeddings(ABC): @@ -760,14 +754,14 @@ class ZeroEntropyEmbeddings(Embeddings): VALID_DIMENSIONS = frozenset({2560, 1280, 640, 320, 160, 80, 40}) VALID_ENCODING_FORMATS = frozenset({"float", "base64"}) VALID_LATENCIES = frozenset({"fast", "slow"}) - DEFAULT_BASE_URL = DEFAULT_EMBEDDINGS_ZEROENTROPY_BASE_URL + DEFAULT_BASE_URL = DEFAULT_ZEROENTROPY_BASE_URL EMBED_PATH = "/v1/models/embed" def __init__( self, api_key: str, model: str = DEFAULT_EMBEDDINGS_ZEROENTROPY_MODEL, - base_url: str = DEFAULT_EMBEDDINGS_ZEROENTROPY_BASE_URL, + base_url: str | None = None, dimensions: int = DEFAULT_EMBEDDINGS_ZEROENTROPY_DIMENSIONS, batch_size: int = DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE, encoding_format: str = DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT, @@ -790,7 +784,8 @@ def __init__( self.api_key = api_key self.model = model - self.base_url = base_url.rstrip("/") + self.base_url = base_url.rstrip("/") if base_url else self.DEFAULT_BASE_URL + self.embed_url = f"{self.base_url}{self.EMBED_PATH}" self.dimensions = dimensions self.batch_size = batch_size self.encoding_format = cast(ZeroEntropyEncodingFormat, encoding_format) @@ -850,8 +845,6 @@ def _encode_with_input_type(self, texts: list[str], input_type: ZeroEntropyInput return [] all_embeddings: list[list[float]] = [] - embed_url = self._embed_url() - for i in range(0, len(texts), self.batch_size): batch = texts[i : i + self.batch_size] request = _ZeroEntropyEmbedRequest( @@ -864,7 +857,7 @@ def _encode_with_input_type(self, texts: list[str], input_type: ZeroEntropyInput ) try: - response = self._client.post(embed_url, json=request.model_dump(exclude_none=True)) + response = self._client.post(self.embed_url, json=request.model_dump(exclude_none=True)) response.raise_for_status() except httpx.HTTPError as e: raise RuntimeError(f"ZeroEntropy embedding request failed: {e}") from e @@ -879,13 +872,6 @@ def _encode_with_input_type(self, texts: list[str], input_type: ZeroEntropyInput return all_embeddings - def _embed_url(self) -> str: - if self.base_url.endswith(self.EMBED_PATH): - return self.base_url - if self.base_url.endswith("/v1"): - return f"{self.base_url}/models/embed" - return f"{self.base_url}{self.EMBED_PATH}" - @staticmethod def _parse_embedding(embedding: list[float] | str) -> list[float]: if not isinstance(embedding, str): @@ -1439,7 +1425,7 @@ def create_embeddings_from_env() -> Embeddings: return ZeroEntropyEmbeddings( api_key=api_key, model=config.embeddings_zeroentropy_model, - base_url=config.embeddings_zeroentropy_base_url or DEFAULT_EMBEDDINGS_ZEROENTROPY_BASE_URL, + base_url=config.embeddings_zeroentropy_base_url, dimensions=config.embeddings_zeroentropy_dimensions, batch_size=config.embeddings_zeroentropy_batch_size, encoding_format=config.embeddings_zeroentropy_encoding_format, diff --git a/hindsight-api-slim/hindsight_api/engine/retain/embedding_utils.py b/hindsight-api-slim/hindsight_api/engine/retain/embedding_utils.py index be1e003fbc..174598b3cd 100644 --- a/hindsight-api-slim/hindsight_api/engine/retain/embedding_utils.py +++ b/hindsight-api-slim/hindsight_api/engine/retain/embedding_utils.py @@ -12,7 +12,8 @@ class EmbeddingsBackend(Protocol): - def encode(self, texts: list[str]) -> list[list[float]]: ... + """Minimal duck-typed surface used by retain/recall — the concrete `Embeddings` + ABC supplies default implementations that delegate to `encode()`.""" def encode_query(self, texts: list[str]) -> list[list[float]]: ... @@ -43,13 +44,9 @@ def generate_embedding( def _encode_with_input_type( embeddings_backend: EmbeddingsBackend, texts: list[str], input_type: EmbeddingInputType ) -> list[list[float]]: - encode_query = getattr(type(embeddings_backend), "encode_query", None) - if input_type == "query" and callable(encode_query): + if input_type == "query": return embeddings_backend.encode_query(texts) - encode_documents = getattr(type(embeddings_backend), "encode_documents", None) - if input_type == "document" and callable(encode_documents): - return embeddings_backend.encode_documents(texts) - return embeddings_backend.encode(texts) + return embeddings_backend.encode_documents(texts) async def generate_embeddings_batch( diff --git a/hindsight-api-slim/tests/test_retain_orchestrator_mapping.py b/hindsight-api-slim/tests/test_retain_orchestrator_mapping.py index 1e7fbdda20..e6a51cecdf 100644 --- a/hindsight-api-slim/tests/test_retain_orchestrator_mapping.py +++ b/hindsight-api-slim/tests/test_retain_orchestrator_mapping.py @@ -96,21 +96,21 @@ def test_raises_when_backend_returns_fewer_embeddings(self): # through — `zip(extracted_facts, embeddings)` would otherwise drop # facts and break unit_id alignment downstream. backend = MagicMock() - backend.encode.return_value = [[0.1, 0.2]] # only 1 vector for 3 inputs + backend.encode_documents.return_value = [[0.1, 0.2]] # only 1 vector for 3 inputs with pytest.raises(RuntimeError, match="returned 1 vectors for 3 input texts"): asyncio.run(embedding_utils.generate_embeddings_batch(backend, ["a", "b", "c"])) def test_raises_when_backend_returns_more_embeddings(self): backend = MagicMock() - backend.encode.return_value = [[0.1], [0.2], [0.3]] + backend.encode_documents.return_value = [[0.1], [0.2], [0.3]] with pytest.raises(RuntimeError, match="returned 3 vectors for 2 input texts"): asyncio.run(embedding_utils.generate_embeddings_batch(backend, ["a", "b"])) def test_passes_through_aligned_embeddings(self): backend = MagicMock() - backend.encode.return_value = [[0.1], [0.2]] + backend.encode_documents.return_value = [[0.1], [0.2]] result = asyncio.run(embedding_utils.generate_embeddings_batch(backend, ["a", "b"])) diff --git a/hindsight-api-slim/tests/test_zeroentropy_embeddings.py b/hindsight-api-slim/tests/test_zeroentropy_embeddings.py index 53c391e6f7..c2c38f3f4c 100644 --- a/hindsight-api-slim/tests/test_zeroentropy_embeddings.py +++ b/hindsight-api-slim/tests/test_zeroentropy_embeddings.py @@ -162,6 +162,29 @@ def handler(request: httpx.Request) -> httpx.Response: assert all(request.latency == "fast" for request in requests) +def test_zeroentropy_omits_latency_when_unset(): + import json + + from hindsight_api.engine.embeddings import ZeroEntropyEmbeddings + + seen_bodies: list[dict] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_bodies.append(json.loads(request.content)) + return httpx.Response(200, json={"results": [{"embedding": [1.0, 2.0]}]}) + + embeddings = ZeroEntropyEmbeddings(api_key="ze-test", dimensions=1280, latency=None) + embeddings._client = httpx.Client( + transport=httpx.MockTransport(handler), + headers={"Authorization": "Bearer ze-test", "Content-Type": "application/json"}, + ) + embeddings._dimension = 1280 + + embeddings.encode_documents(["alpha"]) + + assert "latency" not in seen_bodies[0] + + def test_zeroentropy_encode_query_sends_query_input_type(): from hindsight_api.engine.embeddings import ZeroEntropyEmbeddings diff --git a/hindsight-api-slim/tests/test_zeroentropy_live.py b/hindsight-api-slim/tests/test_zeroentropy_live.py new file mode 100644 index 0000000000..9ac584e6ab --- /dev/null +++ b/hindsight-api-slim/tests/test_zeroentropy_live.py @@ -0,0 +1,89 @@ +"""Live ZeroEntropy API integration tests for both embeddings (zembed-1) and the reranker (zerank-2). + +These tests hit the real ZeroEntropy API. They are skipped unless ZEROENTROPY_LIVE_API_KEY +is set, so CI and local default runs are unaffected. Set the env var to exercise the +full request/response path against the production endpoint. +""" + +import os + +import pytest + +LIVE_API_KEY = os.environ.get("ZEROENTROPY_LIVE_API_KEY") +SKIP_REASON = "ZEROENTROPY_LIVE_API_KEY not set - skipping live ZeroEntropy integration test" + + +@pytest.mark.skipif(not LIVE_API_KEY, reason=SKIP_REASON) +@pytest.mark.asyncio +async def test_live_zeroentropy_embeddings_document_and_query(): + """zembed-1 returns 1280-dim float vectors for both document and query input types, + and the two input types yield distinct vectors for the same text (asymmetric encoder).""" + from hindsight_api.engine.embeddings import ZeroEntropyEmbeddings + + assert LIVE_API_KEY is not None + embeddings = ZeroEntropyEmbeddings(api_key=LIVE_API_KEY, dimensions=1280) + await embeddings.initialize() + + docs = embeddings.encode_documents(["Paris is the capital of France.", "Python is a programming language."]) + assert len(docs) == 2 + assert all(len(v) == 1280 for v in docs) + assert all(isinstance(x, float) for x in docs[0]) + + queries = embeddings.encode_query(["What is the capital of France?"]) + assert len(queries) == 1 + assert len(queries[0]) == 1280 + + # Asymmetric encoder: same text embedded as document vs query should differ. + same_text = "Paris is the capital of France." + doc_vec = embeddings.encode_documents([same_text])[0] + query_vec = embeddings.encode_query([same_text])[0] + assert doc_vec != query_vec + + +@pytest.mark.skipif(not LIVE_API_KEY, reason=SKIP_REASON) +@pytest.mark.asyncio +async def test_live_zeroentropy_embeddings_base64_matches_float(): + """The base64 response encoding decodes to the same vectors (within float tolerance) + as the float response encoding.""" + from hindsight_api.engine.embeddings import ZeroEntropyEmbeddings + + assert LIVE_API_KEY is not None + text = "ZeroEntropy supports Matryoshka embeddings." + + float_provider = ZeroEntropyEmbeddings(api_key=LIVE_API_KEY, dimensions=640, encoding_format="float") + await float_provider.initialize() + float_vec = float_provider.encode_documents([text])[0] + + base64_provider = ZeroEntropyEmbeddings(api_key=LIVE_API_KEY, dimensions=640, encoding_format="base64") + await base64_provider.initialize() + base64_vec = base64_provider.encode_documents([text])[0] + + assert len(float_vec) == 640 + assert len(base64_vec) == 640 + # Same text + same dimensions through different transport encodings should match within float32 precision. + assert all(abs(a - b) < 1e-5 for a, b in zip(float_vec, base64_vec, strict=True)) + + +@pytest.mark.skipif(not LIVE_API_KEY, reason=SKIP_REASON) +@pytest.mark.asyncio +async def test_live_zeroentropy_reranker_orders_by_relevance(): + """zerank-2 returns higher scores for more relevant passages.""" + from hindsight_api.engine.cross_encoder import ZeroEntropyCrossEncoder + + assert LIVE_API_KEY is not None + encoder = ZeroEntropyCrossEncoder(api_key=LIVE_API_KEY) + await encoder.initialize() + + query = "What is the capital of France?" + pairs = [ + (query, "Paris is the capital and most populous city of France."), + (query, "Python is a high-level programming language."), + (query, "The Pacific Ocean is the largest body of water on Earth."), + ] + + scores = await encoder.predict(pairs) + + assert len(scores) == 3 + # Relevant passage should outrank the unrelated ones. + assert scores[0] > scores[1] + assert scores[0] > scores[2]