Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions src/omop_emb/embeddings/embedding_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from enum import StrEnum

import numpy as np
from openai import OpenAI
from openai import APIError, OpenAI

from .embedding_providers import EmbeddingProvider, get_provider_from_provider_type
from omop_emb.config import OmopEmbConfig, ProviderType
Expand Down Expand Up @@ -164,9 +164,17 @@ def embedding_dim(self) -> int:
"Provider cannot discover embedding dimension automatically. "
"Probing via a test API call. This happens once and is then cached."
)
response = self._base_client.embeddings.create(
model=self._model, input=["test"]
)
try:
response = self._base_client.embeddings.create(
model=self._model, input=["test"]
)
except APIError as exc:
raise EmbeddingClientError(
f"Embedding dimension probe failed for model {self._model!r}. "
f"Possible causes include an unreachable/misconfigured endpoint, "
f"auth, or an invalid model name — see the original error for the "
f"actual cause: {exc}"
) from exc
dim = len(response.data[0].embedding)
self._embedding_dim = dim
logger.info(f"Embedding dimension discovered via live probe: {dim}.")
Expand Down Expand Up @@ -208,9 +216,19 @@ def embeddings(
for start in range(0, len(text), batch_size):
chunk = text[start : start + batch_size]
logger.debug(f"Embedding batch [{start}:{start + len(chunk)}]")
response = self._base_client.embeddings.create(
model=self._model, input=chunk
)
try:
response = self._base_client.embeddings.create(
model=self._model, input=chunk
)
except APIError as exc:
longest = max(len(t) for t in chunk)
raise EmbeddingClientError(
f"Embedding request failed for model {self._model!r} "
f"({len(chunk)} text(s) in this batch, longest {longest} chars). "
f"Possible causes include exceeding the model's maximum input/context "
f"length, but could equally be auth, rate-limit, or connectivity issues "
f"Original Error: {exc}"
) from exc
buffer.extend(emb.embedding for emb in response.data)

result = np.array(buffer)
Expand Down
43 changes: 43 additions & 0 deletions tests/test_embedding_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@

from unittest.mock import MagicMock, Mock, patch

import httpx
import numpy as np
import pytest
from openai import APIError

from omop_emb.config import OmopEmbConfig, ProviderType
from omop_emb.embeddings import EmbeddingClient, EmbeddingRole, OllamaProvider
Expand Down Expand Up @@ -164,6 +166,22 @@ def test_cached_after_first_access(self, mock_openai):
_ = client.embedding_dim
provider.get_embedding_dim.assert_called_once()

def test_live_probe_api_error_wrapped_as_embedding_client_error(self, mock_openai):
"""When the provider can't discover the dim, the live-probe call is used —
a raw provider APIError there must not leak past the client unwrapped."""
_, oi = mock_openai
provider = self._mock_provider(dim=0)
provider.get_embedding_dim.return_value = None
oi.embeddings.create.side_effect = APIError(
"boom", httpx.Request("POST", "http://test/v1/embeddings"), body=None
)
client = EmbeddingClient(
model=OLLAMA_MODEL, api_base=OLLAMA_BASE, provider=provider
)

with pytest.raises(EmbeddingClientError, match="dimension probe failed"):
_ = client.embedding_dim


# ---------------------------------------------------------------------------
# embeddings(): batching, shapes, input coercions
Expand Down Expand Up @@ -226,6 +244,31 @@ def test_batching_splits_texts_into_chunks(self, mock_openai):
assert oi.embeddings.create.call_count == 2
assert result.shape == (3, 2)

def test_api_error_wrapped_as_embedding_client_error(self, client):
"""A raw provider APIError must not leak past the client unwrapped."""
c, oi = client
original = APIError(
"maximum context length exceeded",
httpx.Request("POST", "http://test/v1/embeddings"),
body=None,
)
oi.embeddings.create.side_effect = original

with pytest.raises(EmbeddingClientError) as exc_info:
c.embeddings("some very long text", embedding_role=EmbeddingRole.DOCUMENT)

assert exc_info.value.__cause__ is original

def test_api_error_message_is_generic_not_a_diagnosis(self, client):
"""The wrapped message must not assert a specific cause it can't confirm."""
c, oi = client
oi.embeddings.create.side_effect = APIError(
"boom", httpx.Request("POST", "http://test/v1/embeddings"), body=None
)

with pytest.raises(EmbeddingClientError, match="Possible causes include"):
c.embeddings("text", embedding_role=EmbeddingRole.DOCUMENT)

def test_texts_exactly_filling_batch_produce_one_api_call(self, mock_openai):
_, oi = mock_openai
c = EmbeddingClient(
Expand Down
Loading