From a47336eaabf3b3e3dff2f9d356b80fd089b840a9 Mon Sep 17 00:00:00 2001 From: Nico Loesch Date: Thu, 16 Jul 2026 04:50:40 +0000 Subject: [PATCH 1/2] Capture APIError of OpenAI during embeddings request --- src/omop_emb/embeddings/embedding_client.py | 32 +++++++++++---- tests/test_embedding_client.py | 43 +++++++++++++++++++++ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/omop_emb/embeddings/embedding_client.py b/src/omop_emb/embeddings/embedding_client.py index 99535df..64a054b 100644 --- a/src/omop_emb/embeddings/embedding_client.py +++ b/src/omop_emb/embeddings/embedding_client.py @@ -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 @@ -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}.") @@ -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"— see the original error for the actual cause: {exc}" + ) from exc buffer.extend(emb.embedding for emb in response.data) result = np.array(buffer) diff --git a/tests/test_embedding_client.py b/tests/test_embedding_client.py index b665626..1afa2c3 100644 --- a/tests/test_embedding_client.py +++ b/tests/test_embedding_client.py @@ -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 @@ -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 @@ -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( From 34ca6d77a84551925bc3e9dbe7de71db54ac1943 Mon Sep 17 00:00:00 2001 From: Nico Loesch Date: Thu, 16 Jul 2026 04:53:02 +0000 Subject: [PATCH 2/2] Small adaptation to the error message --- src/omop_emb/embeddings/embedding_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/omop_emb/embeddings/embedding_client.py b/src/omop_emb/embeddings/embedding_client.py index 64a054b..4e71ec5 100644 --- a/src/omop_emb/embeddings/embedding_client.py +++ b/src/omop_emb/embeddings/embedding_client.py @@ -227,7 +227,7 @@ def embeddings( 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"— see the original error for the actual cause: {exc}" + f"Original Error: {exc}" ) from exc buffer.extend(emb.embedding for emb in response.data)