From aa350745ec254905ea47d93feeb996c5bc775749 Mon Sep 17 00:00:00 2001 From: Aryan Pardeshi Date: Sun, 9 Aug 2026 12:38:05 +0530 Subject: [PATCH 1/5] fix: raise provider-specific errors from vectorizer _set_model_dims() Each vectorizer probes its provider with a throwaway embedding call to learn the model dimensionality. On failure every one of the eight raised the same generic message under a 'TODO get more specific' comment, which told the caller nothing about which provider rejected them or what to change. Catch the exception classes each SDK actually raises and report the provider, the model, and the concrete next step. The broad 'except Exception' stays as a final clause so an unanticipated error still surfaces as a ValueError rather than escaping raw. --- redisvl/utils/vectorize/bedrock.py | 38 ++++- redisvl/utils/vectorize/text/azureopenai.py | 27 +++- redisvl/utils/vectorize/text/cohere.py | 25 +++- redisvl/utils/vectorize/text/huggingface.py | 17 ++- redisvl/utils/vectorize/text/mistral.py | 15 +- redisvl/utils/vectorize/text/openai.py | 25 +++- redisvl/utils/vectorize/vertexai.py | 31 ++++- redisvl/utils/vectorize/voyageai.py | 26 +++- tests/unit/test_vectorizer_dim_errors.py | 146 ++++++++++++++++++++ 9 files changed, 334 insertions(+), 16 deletions(-) create mode 100644 tests/unit/test_vectorizer_dim_errors.py diff --git a/redisvl/utils/vectorize/bedrock.py b/redisvl/utils/vectorize/bedrock.py index 3a590b3ec..a169a3d7b 100644 --- a/redisvl/utils/vectorize/bedrock.py +++ b/redisvl/utils/vectorize/bedrock.py @@ -189,15 +189,49 @@ def _set_model_dims(self) -> int: Raises: ValueError: If embedding dimensions cannot be determined """ + from botocore.exceptions import BotoCoreError, ClientError + try: # Call the protected _embed method to avoid caching this test embedding embedding = self._embed("dimension check") return len(embedding) except (KeyError, IndexError) as ke: raise ValueError(f"Unexpected response from the Bedrock API: {str(ke)}") + except ClientError as e: + code = e.response.get("Error", {}).get("Code", "") + if code in ( + "UnrecognizedClientException", + "AccessDeniedException", + "InvalidSignatureException", + "ExpiredTokenException", + ): + raise ValueError( + f"AWS rejected the credentials used while determining embedding " + f"dimensions for Bedrock model '{self.model}'. Check " + f"AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_REGION, and that " + f"the identity is allowed bedrock:InvokeModel: {str(e)}" + ) from e + if code in ("ResourceNotFoundException", "ValidationException"): + raise ValueError( + f"Bedrock did not accept the model id '{self.model}' in this region. " + f"Check the model id and that model access is enabled for your " + f"account in AWS_REGION: {str(e)}" + ) from e + raise ValueError( + f"The Bedrock API returned an error while determining embedding " + f"dimensions for model '{self.model}': {str(e)}" + ) from e + except BotoCoreError as e: + raise ValueError( + f"Could not reach Bedrock while determining embedding dimensions for " + f"model '{self.model}'. Check network access, AWS_REGION and any proxy " + f"configuration: {str(e)}" + ) from e except Exception as e: # pylint: disable=broad-except - # fall back (TODO get more specific) - raise ValueError(f"Error setting embedding model dimensions: {str(e)}") + raise ValueError( + f"Error setting embedding model dimensions for Bedrock model " + f"'{self.model}': {str(e)}" + ) from e @retry( wait=wait_random_exponential(min=1, max=60), diff --git a/redisvl/utils/vectorize/text/azureopenai.py b/redisvl/utils/vectorize/text/azureopenai.py index 904c6228b..ca73d1f84 100644 --- a/redisvl/utils/vectorize/text/azureopenai.py +++ b/redisvl/utils/vectorize/text/azureopenai.py @@ -204,15 +204,38 @@ def _set_model_dims(self) -> int: Raises: ValueError: If embedding dimensions cannot be determined """ + import openai + try: # Call the protected _embed method to avoid caching this test embedding embedding = self._embed("dimension check") return len(embedding) except (KeyError, IndexError) as ke: raise ValueError(f"Unexpected response from the AzureOpenAI API: {str(ke)}") + except (openai.AuthenticationError, openai.PermissionDeniedError) as e: + raise ValueError( + f"Azure OpenAI rejected the credentials used while determining embedding " + f"dimensions for deployment '{self.model}'. Check the api_key and " + f"azure_endpoint given in api_config, or the AZURE_OPENAI_API_KEY and " + f"AZURE_OPENAI_ENDPOINT environment variables: {str(e)}" + ) from e + except openai.NotFoundError as e: + raise ValueError( + f"Azure OpenAI does not recognize the deployment '{self.model}'. Azure " + f"addresses models by deployment name rather than model name -- check " + f"that the deployment exists in this resource: {str(e)}" + ) from e + except openai.APIConnectionError as e: + raise ValueError( + f"Could not reach the Azure OpenAI endpoint while determining embedding " + f"dimensions for deployment '{self.model}'. Check the endpoint URL, " + f"network access and any proxy configuration: {str(e)}" + ) from e except Exception as e: # pylint: disable=broad-except - # fall back (TODO get more specific) - raise ValueError(f"Error setting embedding model dimensions: {str(e)}") + raise ValueError( + f"Error setting embedding model dimensions for Azure OpenAI deployment " + f"'{self.model}': {str(e)}" + ) from e @deprecated_argument("text", "content") @retry( diff --git a/redisvl/utils/vectorize/text/cohere.py b/redisvl/utils/vectorize/text/cohere.py index d5b2270ac..8233d3193 100644 --- a/redisvl/utils/vectorize/text/cohere.py +++ b/redisvl/utils/vectorize/text/cohere.py @@ -162,15 +162,36 @@ def _set_model_dims(self) -> int: Raises: ValueError: If embedding dimensions cannot be determined """ + import cohere + from cohere.core.api_error import ApiError + try: # Call the protected _embed method to avoid caching this test embedding embedding = self._embed("dimension check", input_type="search_document") return len(embedding) except (KeyError, IndexError) as ke: raise ValueError(f"Unexpected response from the Cohere API: {str(ke)}") + except cohere.UnauthorizedError as e: + raise ValueError( + f"Cohere rejected the credentials used while determining embedding " + f"dimensions for model '{self.model}'. Check the api_key given in " + f"api_config, or the COHERE_API_KEY environment variable: {str(e)}" + ) from e + except cohere.NotFoundError as e: + raise ValueError( + f"Cohere does not recognize the embedding model '{self.model}'. Check " + f"the model name against the provider's current model list: {str(e)}" + ) from e + except ApiError as e: + raise ValueError( + f"The Cohere API returned an error while determining embedding " + f"dimensions for model '{self.model}': {str(e)}" + ) from e except Exception as e: # pylint: disable=broad-except - # fall back (TODO get more specific) - raise ValueError(f"Error setting embedding model dimensions: {str(e)}") + raise ValueError( + f"Error setting embedding model dimensions for Cohere model " + f"'{self.model}': {str(e)}" + ) from e def _get_cohere_embedding_type(self, dtype: str) -> list[str]: """ diff --git a/redisvl/utils/vectorize/text/huggingface.py b/redisvl/utils/vectorize/text/huggingface.py index 5fb6e8bc4..6adad40dc 100644 --- a/redisvl/utils/vectorize/text/huggingface.py +++ b/redisvl/utils/vectorize/text/huggingface.py @@ -120,9 +120,22 @@ def _set_model_dims(self): embedding = self._embed("dimension check") except (KeyError, IndexError) as ke: raise ValueError(f"Empty response from the embedding model: {str(ke)}") + except OSError as e: + raise ValueError( + f"Could not load the local embedding model '{self.model}'. Check the " + f"model name or path and that the model has been downloaded: {str(e)}" + ) from e + except RuntimeError as e: + raise ValueError( + f"The local embedding model '{self.model}' failed while determining its " + f"dimensions. On CUDA this is commonly an out-of-memory or device " + f"mismatch -- retry with device='cpu': {str(e)}" + ) from e except Exception as e: # pylint: disable=broad-except - # fall back (TODO get more specific) - raise ValueError(f"Error setting embedding model dimensions: {str(e)}") + raise ValueError( + f"Error setting embedding model dimensions for local model " + f"'{self.model}': {str(e)}" + ) from e return len(embedding) @deprecated_argument("text", "content") diff --git a/redisvl/utils/vectorize/text/mistral.py b/redisvl/utils/vectorize/text/mistral.py index b1ba36da9..00cb5c41d 100644 --- a/redisvl/utils/vectorize/text/mistral.py +++ b/redisvl/utils/vectorize/text/mistral.py @@ -153,15 +153,26 @@ def _set_model_dims(self) -> int: Raises: ValueError: If embedding dimensions cannot be determined """ + from mistralai.models import SDKError + try: # Call the protected _embed method to avoid caching this test embedding embedding = self._embed("dimension check") return len(embedding) except (KeyError, IndexError) as ke: raise ValueError(f"Unexpected response from the MISTRAL API: {str(ke)}") + except SDKError as e: + raise ValueError( + f"The Mistral API returned an error while determining embedding " + f"dimensions for model '{self.model}'. If this is an authentication " + f"failure check the api_key given in api_config or the MISTRAL_API_KEY " + f"environment variable; if it is a 404 check the model name: {str(e)}" + ) from e except Exception as e: # pylint: disable=broad-except - # fall back (TODO get more specific) - raise ValueError(f"Error setting embedding model dimensions: {str(e)}") + raise ValueError( + f"Error setting embedding model dimensions for Mistral model " + f"'{self.model}': {str(e)}" + ) from e @deprecated_argument("text", "content") @retry( diff --git a/redisvl/utils/vectorize/text/openai.py b/redisvl/utils/vectorize/text/openai.py index 5102a8f82..da4a703fa 100644 --- a/redisvl/utils/vectorize/text/openai.py +++ b/redisvl/utils/vectorize/text/openai.py @@ -152,15 +152,36 @@ def _set_model_dims(self) -> int: Raises: ValueError: If embedding dimensions cannot be determined """ + import openai + try: # Use the parent embed() method which handles caching embedding = self._embed("dimension check") return len(embedding) except (KeyError, IndexError) as ke: raise ValueError(f"Unexpected response from the OpenAI API: {str(ke)}") + except (openai.AuthenticationError, openai.PermissionDeniedError) as e: + raise ValueError( + f"OpenAI rejected the credentials used while determining embedding " + f"dimensions for model '{self.model}'. Check the api_key given in " + f"api_config, or the OPENAI_API_KEY environment variable: {str(e)}" + ) from e + except openai.NotFoundError as e: + raise ValueError( + f"OpenAI does not recognize the embedding model '{self.model}'. Check " + f"the model name against the provider's current model list: {str(e)}" + ) from e + except openai.APIConnectionError as e: + raise ValueError( + f"Could not reach the OpenAI API while determining embedding dimensions " + f"for model '{self.model}'. Check network access and any proxy " + f"configuration: {str(e)}" + ) from e except Exception as e: # pylint: disable=broad-except - # fall back (TODO get more specific) - raise ValueError(f"Error setting embedding model dimensions: {str(e)}") + raise ValueError( + f"Error setting embedding model dimensions for OpenAI model " + f"'{self.model}': {str(e)}" + ) from e @deprecated_argument("text", "content") @retry( diff --git a/redisvl/utils/vectorize/vertexai.py b/redisvl/utils/vectorize/vertexai.py index 6ae47a5cf..60f3e136a 100644 --- a/redisvl/utils/vectorize/vertexai.py +++ b/redisvl/utils/vectorize/vertexai.py @@ -228,15 +228,42 @@ def _set_model_dims(self) -> int: Raises: ValueError: If embedding dimensions cannot be determined """ + from google.api_core.exceptions import ( + GoogleAPICallError, + NotFound, + PermissionDenied, + Unauthenticated, + ) + try: # Call the protected _embed method to avoid caching this test embedding embedding = self._embed("dimension check") return len(embedding) except (KeyError, IndexError) as ke: raise ValueError(f"Unexpected response from the VertexAI API: {str(ke)}") + except (PermissionDenied, Unauthenticated) as e: + raise ValueError( + f"Google Cloud rejected the credentials used while determining embedding " + f"dimensions for model '{self.model}'. Check " + f"GOOGLE_APPLICATION_CREDENTIALS and that the service account holds the " + f"Vertex AI User role: {str(e)}" + ) from e + except NotFound as e: + raise ValueError( + f"Vertex AI could not find the embedding model '{self.model}' in the " + f"configured project and location. Check the model name, GCP_PROJECT_ID " + f"and GCP_LOCATION: {str(e)}" + ) from e + except GoogleAPICallError as e: + raise ValueError( + f"The Vertex AI API returned an error while determining embedding " + f"dimensions for model '{self.model}': {str(e)}" + ) from e except Exception as e: # pylint: disable=broad-except - # fall back (TODO get more specific) - raise ValueError(f"Error setting embedding model dimensions: {str(e)}") + raise ValueError( + f"Error setting embedding model dimensions for Vertex AI model " + f"'{self.model}': {str(e)}" + ) from e @retry( wait=wait_random_exponential(min=1, max=60), diff --git a/redisvl/utils/vectorize/voyageai.py b/redisvl/utils/vectorize/voyageai.py index e50fab7c4..2c30107fc 100644 --- a/redisvl/utils/vectorize/voyageai.py +++ b/redisvl/utils/vectorize/voyageai.py @@ -230,15 +230,37 @@ def _set_model_dims(self) -> int: Raises: ValueError: If embedding dimensions cannot be determined """ + import voyageai.error + try: # Call the protected _embed method to avoid caching this test embedding embedding = self._embed("dimension check", input_type="document") return len(embedding) except (KeyError, IndexError) as ke: raise ValueError(f"Unexpected response from the VoyageAI API: {str(ke)}") + except voyageai.error.AuthenticationError as e: + raise ValueError( + f"VoyageAI rejected the credentials used while determining embedding " + f"dimensions for model '{self.model}'. Check the api_key given in " + f"api_config, or the VOYAGE_API_KEY environment variable: {str(e)}" + ) from e + except voyageai.error.InvalidRequestError as e: + raise ValueError( + f"VoyageAI rejected the request used to determine embedding dimensions " + f"for model '{self.model}'. This usually means the model name is not " + f"recognized: {str(e)}" + ) from e + except voyageai.error.APIConnectionError as e: + raise ValueError( + f"Could not reach the VoyageAI API while determining embedding " + f"dimensions for model '{self.model}'. Check network access and any " + f"proxy configuration: {str(e)}" + ) from e except Exception as e: # pylint: disable=broad-except - # fall back (TODO get more specific) - raise ValueError(f"Error setting embedding model dimensions: {str(e)}") + raise ValueError( + f"Error setting embedding model dimensions for VoyageAI model " + f"'{self.model}': {str(e)}" + ) from e def _get_batch_size(self) -> int: """ diff --git a/tests/unit/test_vectorizer_dim_errors.py b/tests/unit/test_vectorizer_dim_errors.py new file mode 100644 index 000000000..890033d58 --- /dev/null +++ b/tests/unit/test_vectorizer_dim_errors.py @@ -0,0 +1,146 @@ +"""Provider-specific error messages from vectorizer ``_set_model_dims()``. + +Each vectorizer probes its provider with a throwaway embedding call to learn the +model's dimensionality. When that probe fails, the resulting ``ValueError`` should +name the provider, the model, and what the caller can do about it -- not just +restate the SDK's own message. + +Every vectorizer is driven through its real ``__init__`` with the network client +stubbed out, so these tests exercise the same path a user hits on a bad API key. +""" + +from unittest.mock import patch + +import httpx +import pytest + + +def _openai_error(cls, status): + """Build an openai SDK error without performing a request.""" + request = httpx.Request("POST", "https://api.openai.com/v1/embeddings") + response = httpx.Response(status, request=request) + return cls("boom", response=response, body=None) + + +@pytest.mark.parametrize( + "status, error_name, expected", + [ + (401, "AuthenticationError", "OPENAI_API_KEY"), + (404, "NotFoundError", "does not recognize the embedding model"), + ], +) +def test_openai_dim_probe_reports_provider_and_remediation( + status, error_name, expected +): + import openai + + from redisvl.utils.vectorize.text.openai import OpenAITextVectorizer + + error = _openai_error(getattr(openai, error_name), status) + + with patch.object( + OpenAITextVectorizer, "_initialize_clients", lambda self, *a, **k: None + ): + with patch.object(OpenAITextVectorizer, "_embed", side_effect=error): + with pytest.raises(ValueError) as excinfo: + OpenAITextVectorizer(model="text-embedding-3-small") + + message = str(excinfo.value) + assert "text-embedding-3-small" in message + assert expected in message + + +def test_azure_openai_dim_probe_names_the_deployment(): + import openai + + from redisvl.utils.vectorize.text.azureopenai import AzureOpenAITextVectorizer + + error = _openai_error(openai.NotFoundError, 404) + + with patch.object( + AzureOpenAITextVectorizer, "_initialize_clients", lambda self, *a, **k: None + ): + with patch.object(AzureOpenAITextVectorizer, "_embed", side_effect=error): + with pytest.raises(ValueError) as excinfo: + AzureOpenAITextVectorizer(model="my-deployment") + + message = str(excinfo.value) + assert "my-deployment" in message + # Azure addresses models by deployment name; the message must say so. + assert "deployment" in message + + +def test_bedrock_dim_probe_distinguishes_auth_from_bad_model_id(): + from botocore.exceptions import ClientError + + from redisvl.utils.vectorize.bedrock import BedrockVectorizer + + denied = ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "nope"}}, "InvokeModel" + ) + + with patch.object( + BedrockVectorizer, "_initialize_client", lambda self, *a, **k: None + ): + with patch.object(BedrockVectorizer, "_embed", side_effect=denied): + with pytest.raises(ValueError) as excinfo: + BedrockVectorizer(model="amazon.titan-embed-text-v2:0") + + message = str(excinfo.value) + assert "amazon.titan-embed-text-v2:0" in message + assert "bedrock:InvokeModel" in message + + +def test_bedrock_dim_probe_reports_unknown_model_id(): + from botocore.exceptions import ClientError + + from redisvl.utils.vectorize.bedrock import BedrockVectorizer + + missing = ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "nope"}}, + "InvokeModel", + ) + + with patch.object( + BedrockVectorizer, "_initialize_client", lambda self, *a, **k: None + ): + with patch.object(BedrockVectorizer, "_embed", side_effect=missing): + with pytest.raises(ValueError) as excinfo: + BedrockVectorizer(model="not-a-real-model") + + message = str(excinfo.value) + assert "not-a-real-model" in message + assert "AWS_REGION" in message + + +def test_huggingface_dim_probe_reports_local_model_load_failure(): + from redisvl.utils.vectorize.text.huggingface import HFTextVectorizer + + with patch.object( + HFTextVectorizer, "_initialize_client", lambda self, *a, **k: None + ): + with patch.object( + HFTextVectorizer, "_embed", side_effect=OSError("no such file") + ): + with pytest.raises(ValueError) as excinfo: + HFTextVectorizer(model="sentence-transformers/all-mpnet-base-v2") + + message = str(excinfo.value) + assert "sentence-transformers/all-mpnet-base-v2" in message + assert "downloaded" in message + + +def test_unanticipated_errors_still_become_valueerror(): + """The generic fallback must survive: no error may escape raw.""" + from redisvl.utils.vectorize.text.openai import OpenAITextVectorizer + + with patch.object( + OpenAITextVectorizer, "_initialize_clients", lambda self, *a, **k: None + ): + with patch.object( + OpenAITextVectorizer, "_embed", side_effect=ZeroDivisionError("surprise") + ): + with pytest.raises(ValueError) as excinfo: + OpenAITextVectorizer(model="text-embedding-3-small") + + assert "text-embedding-3-small" in str(excinfo.value) From d287656233c28e22fd25f98963a358f3792ac4b7 Mon Sep 17 00:00:00 2001 From: Aryan Pardeshi Date: Mon, 10 Aug 2026 20:11:55 +0530 Subject: [PATCH 2/5] fix: unwrap RetryError/ValueError in _set_model_dims so provider dispatch actually fires Two layers were hiding the real SDK exception from _set_model_dims(): 1. _embed()/_embed_many() already catch the SDK's own exception and re-raise a generic ValueError, so catching the provider exception type directly in _set_model_dims() (as this PR originally did) never triggers -- Cursor Bugbot caught this on review. 2. _embed()/_embed_many() are @retry-decorated with retry_if_not_exception_type(TypeError), which does not exempt that ValueError -- so a permanent failure like bad credentials or an unknown model is retried 6 times with exponential backoff before tenacity gives up and raises RetryError, wrapping the ValueError, which itself wraps the SDK exception. _set_model_dims() now unwraps RetryError.last_attempt.exception() first, then unwraps __cause__/__context__ on what's left, before dispatching on the provider's real exception type. Also fixes HuggingFace separately: a bad model name raises OSError from SentenceTransformer() in _initialize_client(), before _set_model_dims() runs at all -- that OSError is now caught where it actually happens. Tests now drive the real _embed()/_initialize_client() code for OpenAI, Bedrock and HuggingFace (patching only the network client / model load, not _embed itself), with time.sleep patched so retry backoff doesn't stall the suite. The remaining five providers get a cause-dispatch test built the same way _embed really builds its wrapper: raised while handling the SDK exception, so __context__ is set by real Python chaining rather than fabricated. 11 passed in test_vectorizer_dim_errors.py (16s). Full unit suite: 1303 passed, 11 skipped -- no regressions from the previous 1299. --- redisvl/utils/vectorize/bedrock.py | 74 +++--- redisvl/utils/vectorize/text/azureopenai.py | 63 +++-- redisvl/utils/vectorize/text/cohere.py | 54 +++-- redisvl/utils/vectorize/text/huggingface.py | 17 +- redisvl/utils/vectorize/text/mistral.py | 36 ++- redisvl/utils/vectorize/text/openai.py | 59 +++-- redisvl/utils/vectorize/vertexai.py | 58 +++-- redisvl/utils/vectorize/voyageai.py | 58 +++-- tests/unit/test_vectorizer_dim_errors.py | 248 ++++++++++++++++---- 9 files changed, 497 insertions(+), 170 deletions(-) diff --git a/redisvl/utils/vectorize/bedrock.py b/redisvl/utils/vectorize/bedrock.py index a169a3d7b..9b8f598dc 100644 --- a/redisvl/utils/vectorize/bedrock.py +++ b/redisvl/utils/vectorize/bedrock.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any, Literal from pydantic import ConfigDict -from tenacity import retry, stop_after_attempt, wait_random_exponential +from tenacity import RetryError, retry, stop_after_attempt, wait_random_exponential from tenacity.retry import retry_if_not_exception_type if TYPE_CHECKING: @@ -195,37 +195,57 @@ def _set_model_dims(self) -> int: # Call the protected _embed method to avoid caching this test embedding embedding = self._embed("dimension check") return len(embedding) - except (KeyError, IndexError) as ke: - raise ValueError(f"Unexpected response from the Bedrock API: {str(ke)}") - except ClientError as e: - code = e.response.get("Error", {}).get("Code", "") - if code in ( - "UnrecognizedClientException", - "AccessDeniedException", - "InvalidSignatureException", - "ExpiredTokenException", - ): + except (ValueError, RetryError) as e: + # _embed()/_embed_many() are @retry-decorated with + # retry_if_not_exception_type(TypeError), so the ValueError they + # raise on a permanent failure (bad credentials, unknown model...) + # is itself retried until tenacity gives up and raises RetryError. + # Unwrap that first, then unwrap the ValueError it wraps, to reach + # the real SDK exception either way. + root: BaseException = e + if isinstance(root, RetryError): + root = root.last_attempt.exception() or root + # _embed()/_embed_many() wrap the SDK's own exception in a ValueError, + # so dispatch on the wrapped cause instead of the wrapper -- catching + # the provider SDK's exception type here would never fire otherwise. + cause = root.__cause__ or root.__context__ or root + if isinstance(cause, (KeyError, IndexError)): raise ValueError( - f"AWS rejected the credentials used while determining embedding " - f"dimensions for Bedrock model '{self.model}'. Check " - f"AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_REGION, and that " - f"the identity is allowed bedrock:InvokeModel: {str(e)}" + f"Unexpected response from the Bedrock API: {str(cause)}" ) from e - if code in ("ResourceNotFoundException", "ValidationException"): + if isinstance(cause, ClientError): + code = cause.response.get("Error", {}).get("Code", "") + if code in ( + "UnrecognizedClientException", + "AccessDeniedException", + "InvalidSignatureException", + "ExpiredTokenException", + ): + raise ValueError( + f"AWS rejected the credentials used while determining embedding " + f"dimensions for Bedrock model '{self.model}'. Check " + f"AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_REGION, and that " + f"the identity is allowed bedrock:InvokeModel: {str(cause)}" + ) from e + if code in ("ResourceNotFoundException", "ValidationException"): + raise ValueError( + f"Bedrock did not accept the model id '{self.model}' in this region. " + f"Check the model id and that model access is enabled for your " + f"account in AWS_REGION: {str(cause)}" + ) from e raise ValueError( - f"Bedrock did not accept the model id '{self.model}' in this region. " - f"Check the model id and that model access is enabled for your " - f"account in AWS_REGION: {str(e)}" + f"The Bedrock API returned an error while determining embedding " + f"dimensions for model '{self.model}': {str(cause)}" + ) from e + if isinstance(cause, BotoCoreError): + raise ValueError( + f"Could not reach Bedrock while determining embedding dimensions for " + f"model '{self.model}'. Check network access, AWS_REGION and any proxy " + f"configuration: {str(cause)}" ) from e raise ValueError( - f"The Bedrock API returned an error while determining embedding " - f"dimensions for model '{self.model}': {str(e)}" - ) from e - except BotoCoreError as e: - raise ValueError( - f"Could not reach Bedrock while determining embedding dimensions for " - f"model '{self.model}'. Check network access, AWS_REGION and any proxy " - f"configuration: {str(e)}" + f"Error setting embedding model dimensions for Bedrock model " + f"'{self.model}': {str(e)}" ) from e except Exception as e: # pylint: disable=broad-except raise ValueError( diff --git a/redisvl/utils/vectorize/text/azureopenai.py b/redisvl/utils/vectorize/text/azureopenai.py index ca73d1f84..a69a15f1a 100644 --- a/redisvl/utils/vectorize/text/azureopenai.py +++ b/redisvl/utils/vectorize/text/azureopenai.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any from pydantic import ConfigDict -from tenacity import retry, stop_after_attempt, wait_random_exponential +from tenacity import RetryError, retry, stop_after_attempt, wait_random_exponential from tenacity.retry import retry_if_not_exception_type if TYPE_CHECKING: @@ -210,26 +210,49 @@ def _set_model_dims(self) -> int: # Call the protected _embed method to avoid caching this test embedding embedding = self._embed("dimension check") return len(embedding) - except (KeyError, IndexError) as ke: - raise ValueError(f"Unexpected response from the AzureOpenAI API: {str(ke)}") - except (openai.AuthenticationError, openai.PermissionDeniedError) as e: + except (ValueError, RetryError) as e: + # _embed()/_embed_many() are @retry-decorated with + # retry_if_not_exception_type(TypeError), so the ValueError they + # raise on a permanent failure (bad credentials, unknown model...) + # is itself retried until tenacity gives up and raises RetryError. + # Unwrap that first, then unwrap the ValueError it wraps, to reach + # the real SDK exception either way. + root: BaseException = e + if isinstance(root, RetryError): + root = root.last_attempt.exception() or root + # _embed()/_embed_many() wrap the SDK's own exception in a ValueError + # (or TypeError for VoyageAI's InvalidRequestError below), so dispatch + # on the wrapped cause instead of the wrapper -- catching the provider + # SDK's exception type here would never fire otherwise. + cause = root.__cause__ or root.__context__ or root + if isinstance(cause, (KeyError, IndexError)): + raise ValueError( + f"Unexpected response from the AzureOpenAI API: {str(cause)}" + ) from e + if isinstance( + cause, (openai.AuthenticationError, openai.PermissionDeniedError) + ): + raise ValueError( + f"Azure OpenAI rejected the credentials used while determining embedding " + f"dimensions for deployment '{self.model}'. Check the api_key and " + f"azure_endpoint given in api_config, or the AZURE_OPENAI_API_KEY and " + f"AZURE_OPENAI_ENDPOINT environment variables: {str(cause)}" + ) from e + if isinstance(cause, openai.NotFoundError): + raise ValueError( + f"Azure OpenAI does not recognize the deployment '{self.model}'. Azure " + f"addresses models by deployment name rather than model name -- check " + f"that the deployment exists in this resource: {str(cause)}" + ) from e + if isinstance(cause, openai.APIConnectionError): + raise ValueError( + f"Could not reach the Azure OpenAI endpoint while determining embedding " + f"dimensions for deployment '{self.model}'. Check the endpoint URL, " + f"network access and any proxy configuration: {str(cause)}" + ) from e raise ValueError( - f"Azure OpenAI rejected the credentials used while determining embedding " - f"dimensions for deployment '{self.model}'. Check the api_key and " - f"azure_endpoint given in api_config, or the AZURE_OPENAI_API_KEY and " - f"AZURE_OPENAI_ENDPOINT environment variables: {str(e)}" - ) from e - except openai.NotFoundError as e: - raise ValueError( - f"Azure OpenAI does not recognize the deployment '{self.model}'. Azure " - f"addresses models by deployment name rather than model name -- check " - f"that the deployment exists in this resource: {str(e)}" - ) from e - except openai.APIConnectionError as e: - raise ValueError( - f"Could not reach the Azure OpenAI endpoint while determining embedding " - f"dimensions for deployment '{self.model}'. Check the endpoint URL, " - f"network access and any proxy configuration: {str(e)}" + f"Error setting embedding model dimensions for Azure OpenAI deployment " + f"'{self.model}': {str(e)}" ) from e except Exception as e: # pylint: disable=broad-except raise ValueError( diff --git a/redisvl/utils/vectorize/text/cohere.py b/redisvl/utils/vectorize/text/cohere.py index 8233d3193..1aa1b3012 100644 --- a/redisvl/utils/vectorize/text/cohere.py +++ b/redisvl/utils/vectorize/text/cohere.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any from pydantic import ConfigDict -from tenacity import retry, stop_after_attempt, wait_random_exponential +from tenacity import RetryError, retry, stop_after_attempt, wait_random_exponential from tenacity.retry import retry_if_not_exception_type if TYPE_CHECKING: @@ -169,23 +169,43 @@ def _set_model_dims(self) -> int: # Call the protected _embed method to avoid caching this test embedding embedding = self._embed("dimension check", input_type="search_document") return len(embedding) - except (KeyError, IndexError) as ke: - raise ValueError(f"Unexpected response from the Cohere API: {str(ke)}") - except cohere.UnauthorizedError as e: + except (ValueError, RetryError) as e: + # _embed()/_embed_many() are @retry-decorated with + # retry_if_not_exception_type(TypeError), so the ValueError they + # raise on a permanent failure (bad credentials, unknown model...) + # is itself retried until tenacity gives up and raises RetryError. + # Unwrap that first, then unwrap the ValueError it wraps, to reach + # the real SDK exception either way. + root: BaseException = e + if isinstance(root, RetryError): + root = root.last_attempt.exception() or root + # _embed()/_embed_many() wrap the SDK's own exception in a ValueError, + # so dispatch on the wrapped cause instead of the wrapper -- catching + # the provider SDK's exception type here would never fire otherwise. + cause = root.__cause__ or root.__context__ or root + if isinstance(cause, (KeyError, IndexError)): + raise ValueError( + f"Unexpected response from the Cohere API: {str(cause)}" + ) from e + if isinstance(cause, cohere.UnauthorizedError): + raise ValueError( + f"Cohere rejected the credentials used while determining embedding " + f"dimensions for model '{self.model}'. Check the api_key given in " + f"api_config, or the COHERE_API_KEY environment variable: {str(cause)}" + ) from e + if isinstance(cause, cohere.NotFoundError): + raise ValueError( + f"Cohere does not recognize the embedding model '{self.model}'. Check " + f"the model name against the provider's current model list: {str(cause)}" + ) from e + if isinstance(cause, ApiError): + raise ValueError( + f"The Cohere API returned an error while determining embedding " + f"dimensions for model '{self.model}': {str(cause)}" + ) from e raise ValueError( - f"Cohere rejected the credentials used while determining embedding " - f"dimensions for model '{self.model}'. Check the api_key given in " - f"api_config, or the COHERE_API_KEY environment variable: {str(e)}" - ) from e - except cohere.NotFoundError as e: - raise ValueError( - f"Cohere does not recognize the embedding model '{self.model}'. Check " - f"the model name against the provider's current model list: {str(e)}" - ) from e - except ApiError as e: - raise ValueError( - f"The Cohere API returned an error while determining embedding " - f"dimensions for model '{self.model}': {str(e)}" + f"Error setting embedding model dimensions for Cohere model " + f"'{self.model}': {str(e)}" ) from e except Exception as e: # pylint: disable=broad-except raise ValueError( diff --git a/redisvl/utils/vectorize/text/huggingface.py b/redisvl/utils/vectorize/text/huggingface.py index 6adad40dc..6cbb95603 100644 --- a/redisvl/utils/vectorize/text/huggingface.py +++ b/redisvl/utils/vectorize/text/huggingface.py @@ -113,7 +113,22 @@ def _initialize_client(self, model: str, **kwargs): "Please install with `pip install sentence-transformers`" ) - self._client = SentenceTransformer(model, **kwargs) + try: + self._client = SentenceTransformer(model, **kwargs) + except OSError as e: + # This is where a bad model name or path actually surfaces -- + # _set_model_dims() never sees it, since loading happens here, + # before that method's try block runs. + raise ValueError( + f"Could not load the local embedding model '{model}'. Check the " + f"model name or path and that the model has been downloaded: {str(e)}" + ) from e + except RuntimeError as e: + raise ValueError( + f"The local embedding model '{model}' failed to load. On CUDA this is " + f"commonly an out-of-memory or device mismatch -- retry with " + f"device='cpu': {str(e)}" + ) from e def _set_model_dims(self): try: diff --git a/redisvl/utils/vectorize/text/mistral.py b/redisvl/utils/vectorize/text/mistral.py index 00cb5c41d..56b05260f 100644 --- a/redisvl/utils/vectorize/text/mistral.py +++ b/redisvl/utils/vectorize/text/mistral.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any from pydantic import ConfigDict -from tenacity import retry, stop_after_attempt, wait_random_exponential +from tenacity import RetryError, retry, stop_after_attempt, wait_random_exponential from tenacity.retry import retry_if_not_exception_type if TYPE_CHECKING: @@ -159,14 +159,34 @@ def _set_model_dims(self) -> int: # Call the protected _embed method to avoid caching this test embedding embedding = self._embed("dimension check") return len(embedding) - except (KeyError, IndexError) as ke: - raise ValueError(f"Unexpected response from the MISTRAL API: {str(ke)}") - except SDKError as e: + except (ValueError, RetryError) as e: + # _embed()/_embed_many() are @retry-decorated with + # retry_if_not_exception_type(TypeError), so the ValueError they + # raise on a permanent failure (bad credentials, unknown model...) + # is itself retried until tenacity gives up and raises RetryError. + # Unwrap that first, then unwrap the ValueError it wraps, to reach + # the real SDK exception either way. + root: BaseException = e + if isinstance(root, RetryError): + root = root.last_attempt.exception() or root + # _embed()/_embed_many() wrap the SDK's own exception in a ValueError, + # so dispatch on the wrapped cause instead of the wrapper -- catching + # the provider SDK's exception type here would never fire otherwise. + cause = root.__cause__ or root.__context__ or root + if isinstance(cause, (KeyError, IndexError)): + raise ValueError( + f"Unexpected response from the MISTRAL API: {str(cause)}" + ) from e + if isinstance(cause, SDKError): + raise ValueError( + f"The Mistral API returned an error while determining embedding " + f"dimensions for model '{self.model}'. If this is an authentication " + f"failure check the api_key given in api_config or the MISTRAL_API_KEY " + f"environment variable; if it is a 404 check the model name: {str(cause)}" + ) from e raise ValueError( - f"The Mistral API returned an error while determining embedding " - f"dimensions for model '{self.model}'. If this is an authentication " - f"failure check the api_key given in api_config or the MISTRAL_API_KEY " - f"environment variable; if it is a 404 check the model name: {str(e)}" + f"Error setting embedding model dimensions for Mistral model " + f"'{self.model}': {str(e)}" ) from e except Exception as e: # pylint: disable=broad-except raise ValueError( diff --git a/redisvl/utils/vectorize/text/openai.py b/redisvl/utils/vectorize/text/openai.py index da4a703fa..c8a55b5ef 100644 --- a/redisvl/utils/vectorize/text/openai.py +++ b/redisvl/utils/vectorize/text/openai.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any from pydantic import ConfigDict -from tenacity import retry, stop_after_attempt, wait_random_exponential +from tenacity import RetryError, retry, stop_after_attempt, wait_random_exponential from tenacity.retry import retry_if_not_exception_type if TYPE_CHECKING: @@ -158,24 +158,47 @@ def _set_model_dims(self) -> int: # Use the parent embed() method which handles caching embedding = self._embed("dimension check") return len(embedding) - except (KeyError, IndexError) as ke: - raise ValueError(f"Unexpected response from the OpenAI API: {str(ke)}") - except (openai.AuthenticationError, openai.PermissionDeniedError) as e: + except (ValueError, RetryError) as e: + # _embed()/_embed_many() are @retry-decorated with + # retry_if_not_exception_type(TypeError), so the ValueError they + # raise on a permanent failure (bad credentials, unknown model...) + # is itself retried until tenacity gives up and raises RetryError. + # Unwrap that first, then unwrap the ValueError it wraps, to reach + # the real SDK exception either way. + root: BaseException = e + if isinstance(root, RetryError): + root = root.last_attempt.exception() or root + # _embed()/_embed_many() wrap the SDK's own exception in a ValueError + # (or TypeError for VoyageAI's InvalidRequestError below), so dispatch + # on the wrapped cause instead of the wrapper -- catching the provider + # SDK's exception type here would never fire otherwise. + cause = root.__cause__ or root.__context__ or root + if isinstance(cause, (KeyError, IndexError)): + raise ValueError( + f"Unexpected response from the OpenAI API: {str(cause)}" + ) from e + if isinstance( + cause, (openai.AuthenticationError, openai.PermissionDeniedError) + ): + raise ValueError( + f"OpenAI rejected the credentials used while determining embedding " + f"dimensions for model '{self.model}'. Check the api_key given in " + f"api_config, or the OPENAI_API_KEY environment variable: {str(cause)}" + ) from e + if isinstance(cause, openai.NotFoundError): + raise ValueError( + f"OpenAI does not recognize the embedding model '{self.model}'. Check " + f"the model name against the provider's current model list: {str(cause)}" + ) from e + if isinstance(cause, openai.APIConnectionError): + raise ValueError( + f"Could not reach the OpenAI API while determining embedding dimensions " + f"for model '{self.model}'. Check network access and any proxy " + f"configuration: {str(cause)}" + ) from e raise ValueError( - f"OpenAI rejected the credentials used while determining embedding " - f"dimensions for model '{self.model}'. Check the api_key given in " - f"api_config, or the OPENAI_API_KEY environment variable: {str(e)}" - ) from e - except openai.NotFoundError as e: - raise ValueError( - f"OpenAI does not recognize the embedding model '{self.model}'. Check " - f"the model name against the provider's current model list: {str(e)}" - ) from e - except openai.APIConnectionError as e: - raise ValueError( - f"Could not reach the OpenAI API while determining embedding dimensions " - f"for model '{self.model}'. Check network access and any proxy " - f"configuration: {str(e)}" + f"Error setting embedding model dimensions for OpenAI model " + f"'{self.model}': {str(e)}" ) from e except Exception as e: # pylint: disable=broad-except raise ValueError( diff --git a/redisvl/utils/vectorize/vertexai.py b/redisvl/utils/vectorize/vertexai.py index 60f3e136a..9781eaf9c 100644 --- a/redisvl/utils/vectorize/vertexai.py +++ b/redisvl/utils/vectorize/vertexai.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any from pydantic import ConfigDict -from tenacity import retry, stop_after_attempt, wait_random_exponential +from tenacity import RetryError, retry, stop_after_attempt, wait_random_exponential from tenacity.retry import retry_if_not_exception_type if TYPE_CHECKING: @@ -239,25 +239,45 @@ def _set_model_dims(self) -> int: # Call the protected _embed method to avoid caching this test embedding embedding = self._embed("dimension check") return len(embedding) - except (KeyError, IndexError) as ke: - raise ValueError(f"Unexpected response from the VertexAI API: {str(ke)}") - except (PermissionDenied, Unauthenticated) as e: + except (ValueError, RetryError) as e: + # _embed()/_embed_many() are @retry-decorated with + # retry_if_not_exception_type(TypeError), so the ValueError they + # raise on a permanent failure (bad credentials, unknown model...) + # is itself retried until tenacity gives up and raises RetryError. + # Unwrap that first, then unwrap the ValueError it wraps, to reach + # the real SDK exception either way. + root: BaseException = e + if isinstance(root, RetryError): + root = root.last_attempt.exception() or root + # _embed()/_embed_many() wrap the SDK's own exception in a ValueError, + # so dispatch on the wrapped cause instead of the wrapper -- catching + # the provider SDK's exception type here would never fire otherwise. + cause = root.__cause__ or root.__context__ or root + if isinstance(cause, (KeyError, IndexError)): + raise ValueError( + f"Unexpected response from the VertexAI API: {str(cause)}" + ) from e + if isinstance(cause, (PermissionDenied, Unauthenticated)): + raise ValueError( + f"Google Cloud rejected the credentials used while determining embedding " + f"dimensions for model '{self.model}'. Check " + f"GOOGLE_APPLICATION_CREDENTIALS and that the service account holds the " + f"Vertex AI User role: {str(cause)}" + ) from e + if isinstance(cause, NotFound): + raise ValueError( + f"Vertex AI could not find the embedding model '{self.model}' in the " + f"configured project and location. Check the model name, GCP_PROJECT_ID " + f"and GCP_LOCATION: {str(cause)}" + ) from e + if isinstance(cause, GoogleAPICallError): + raise ValueError( + f"The Vertex AI API returned an error while determining embedding " + f"dimensions for model '{self.model}': {str(cause)}" + ) from e raise ValueError( - f"Google Cloud rejected the credentials used while determining embedding " - f"dimensions for model '{self.model}'. Check " - f"GOOGLE_APPLICATION_CREDENTIALS and that the service account holds the " - f"Vertex AI User role: {str(e)}" - ) from e - except NotFound as e: - raise ValueError( - f"Vertex AI could not find the embedding model '{self.model}' in the " - f"configured project and location. Check the model name, GCP_PROJECT_ID " - f"and GCP_LOCATION: {str(e)}" - ) from e - except GoogleAPICallError as e: - raise ValueError( - f"The Vertex AI API returned an error while determining embedding " - f"dimensions for model '{self.model}': {str(e)}" + f"Error setting embedding model dimensions for Vertex AI model " + f"'{self.model}': {str(e)}" ) from e except Exception as e: # pylint: disable=broad-except raise ValueError( diff --git a/redisvl/utils/vectorize/voyageai.py b/redisvl/utils/vectorize/voyageai.py index 2c30107fc..ca7872d4f 100644 --- a/redisvl/utils/vectorize/voyageai.py +++ b/redisvl/utils/vectorize/voyageai.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any from pydantic import ConfigDict -from tenacity import retry, stop_after_attempt, wait_random_exponential +from tenacity import RetryError, retry, stop_after_attempt, wait_random_exponential from tenacity.retry import retry_if_not_exception_type if TYPE_CHECKING: @@ -236,25 +236,45 @@ def _set_model_dims(self) -> int: # Call the protected _embed method to avoid caching this test embedding embedding = self._embed("dimension check", input_type="document") return len(embedding) - except (KeyError, IndexError) as ke: - raise ValueError(f"Unexpected response from the VoyageAI API: {str(ke)}") - except voyageai.error.AuthenticationError as e: + except (ValueError, RetryError) as e: + # _embed()/_embed_many() are @retry-decorated with + # retry_if_not_exception_type(TypeError), so the ValueError they + # raise on a permanent failure (bad credentials, unknown model...) + # is itself retried until tenacity gives up and raises RetryError. + # Unwrap that first, then unwrap the ValueError it wraps, to reach + # the real SDK exception either way. + root: BaseException = e + if isinstance(root, RetryError): + root = root.last_attempt.exception() or root + # _embed()/_embed_many() wrap the SDK's own exception in a ValueError, + # so dispatch on the wrapped cause instead of the wrapper -- catching + # the provider SDK's exception type here would never fire otherwise. + cause = root.__cause__ or root.__context__ or root + if isinstance(cause, (KeyError, IndexError)): + raise ValueError( + f"Unexpected response from the VoyageAI API: {str(cause)}" + ) from e + if isinstance(cause, voyageai.error.AuthenticationError): + raise ValueError( + f"VoyageAI rejected the credentials used while determining embedding " + f"dimensions for model '{self.model}'. Check the api_key given in " + f"api_config, or the VOYAGE_API_KEY environment variable: {str(cause)}" + ) from e + if isinstance(cause, voyageai.error.InvalidRequestError): + raise ValueError( + f"VoyageAI rejected the request used to determine embedding dimensions " + f"for model '{self.model}'. This usually means the model name is not " + f"recognized: {str(cause)}" + ) from e + if isinstance(cause, voyageai.error.APIConnectionError): + raise ValueError( + f"Could not reach the VoyageAI API while determining embedding " + f"dimensions for model '{self.model}'. Check network access and any " + f"proxy configuration: {str(cause)}" + ) from e raise ValueError( - f"VoyageAI rejected the credentials used while determining embedding " - f"dimensions for model '{self.model}'. Check the api_key given in " - f"api_config, or the VOYAGE_API_KEY environment variable: {str(e)}" - ) from e - except voyageai.error.InvalidRequestError as e: - raise ValueError( - f"VoyageAI rejected the request used to determine embedding dimensions " - f"for model '{self.model}'. This usually means the model name is not " - f"recognized: {str(e)}" - ) from e - except voyageai.error.APIConnectionError as e: - raise ValueError( - f"Could not reach the VoyageAI API while determining embedding " - f"dimensions for model '{self.model}'. Check network access and any " - f"proxy configuration: {str(e)}" + f"Error setting embedding model dimensions for VoyageAI model " + f"'{self.model}': {str(e)}" ) from e except Exception as e: # pylint: disable=broad-except raise ValueError( diff --git a/tests/unit/test_vectorizer_dim_errors.py b/tests/unit/test_vectorizer_dim_errors.py index 890033d58..be7e55686 100644 --- a/tests/unit/test_vectorizer_dim_errors.py +++ b/tests/unit/test_vectorizer_dim_errors.py @@ -5,11 +5,27 @@ name the provider, the model, and what the caller can do about it -- not just restate the SDK's own message. -Every vectorizer is driven through its real ``__init__`` with the network client -stubbed out, so these tests exercise the same path a user hits on a bad API key. +``_embed()``/``_embed_many()`` on every provider already catch the SDK's own +exception and re-wrap it in a generic ``ValueError`` before ``_set_model_dims()`` +ever sees it. That means ``_set_model_dims()`` cannot simply catch the SDK's +exception type directly -- it has to unwrap the ``ValueError`` it receives and +dispatch on ``__cause__``/``__context__`` instead. A test that patches ``_embed`` +with ``side_effect=`` skips that wrapping entirely and would pass +even if the dispatch logic were broken, since it hands ``_set_model_dims`` an +exception shape production code never produces. + +So this file mixes two kinds of test: + +- True end-to-end tests (OpenAI, Bedrock, HuggingFace) that patch only the + network client / model-loading call, so the vectorizer's real ``_embed()`` / + ``_initialize_client()`` code runs and performs the real wrapping. +- Cause-dispatch tests for the remaining providers, which hand ``_embed`` a + ``ValueError`` built the same way ``_embed`` really builds one -- raised while + handling the SDK's exception, so ``__context__`` is set by Python's normal + implicit exception chaining, not fabricated by the test. """ -from unittest.mock import patch +from unittest.mock import MagicMock, patch import httpx import pytest @@ -22,6 +38,30 @@ def _openai_error(cls, status): return cls("boom", response=response, body=None) +def _wrapped(cause: BaseException, message: str = "wrapped") -> ValueError: + """Build a ValueError whose __context__ is `cause`. + + This mirrors exactly what `_embed()`/`_embed_many()` produce: they catch the + SDK's exception and raise a generic ValueError while still handling it, which + is what makes Python set __context__ via implicit chaining. Constructing the + ValueError outside of an active `except cause` block would leave __context__ + unset, so `cause` is actually raised and caught here rather than merely + referenced. + """ + try: + raise cause + except type(cause): + try: + raise ValueError(message) + except ValueError as wrapped_error: + return wrapped_error + + +# --------------------------------------------------------------------------- +# End-to-end: the real _embed()/_initialize_client() code runs. +# --------------------------------------------------------------------------- + + @pytest.mark.parametrize( "status, error_name, expected", [ @@ -30,37 +70,134 @@ def _openai_error(cls, status): ], ) def test_openai_dim_probe_reports_provider_and_remediation( - status, error_name, expected + monkeypatch, status, error_name, expected ): + """OpenAI's real client.embeddings.create() raises, _embed() wraps it, and + _set_model_dims() must still recover the real cause and report on it.""" + import time + import openai from redisvl.utils.vectorize.text.openai import OpenAITextVectorizer + # _embed() is @retry-decorated and does not exempt ValueError from retrying, + # so a permanent failure like a 401 is retried up to 6 times with exponential + # backoff before RetryError is raised. Skip the real sleeps -- the retrying + # itself isn't what this test is checking. + monkeypatch.setattr(time, "sleep", lambda *a, **k: None) + error = _openai_error(getattr(openai, error_name), status) + mock_client = MagicMock() + mock_client.embeddings.create.side_effect = error with patch.object( - OpenAITextVectorizer, "_initialize_clients", lambda self, *a, **k: None + OpenAITextVectorizer, + "_initialize_clients", + lambda self, *a, **k: setattr(self, "_client", mock_client), ): - with patch.object(OpenAITextVectorizer, "_embed", side_effect=error): - with pytest.raises(ValueError) as excinfo: - OpenAITextVectorizer(model="text-embedding-3-small") + with pytest.raises(ValueError) as excinfo: + OpenAITextVectorizer(model="text-embedding-3-small") message = str(excinfo.value) assert "text-embedding-3-small" in message assert expected in message +def test_bedrock_dim_probe_distinguishes_auth_from_bad_model_id(monkeypatch): + """Bedrock's real client.invoke_model() raises a ClientError, _embed() wraps + it, and _set_model_dims() must still branch on the AWS error code.""" + import time + + from botocore.exceptions import ClientError + + from redisvl.utils.vectorize.bedrock import BedrockVectorizer + + monkeypatch.setattr(time, "sleep", lambda *a, **k: None) + + denied = ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "nope"}}, "InvokeModel" + ) + mock_client = MagicMock() + mock_client.invoke_model.side_effect = denied + + with patch.object( + BedrockVectorizer, + "_initialize_client", + lambda self, *a, **k: setattr(self, "_client", mock_client), + ): + with pytest.raises(ValueError) as excinfo: + BedrockVectorizer(model="amazon.titan-embed-text-v2:0") + + message = str(excinfo.value) + assert "amazon.titan-embed-text-v2:0" in message + assert "bedrock:InvokeModel" in message + + +def test_bedrock_dim_probe_reports_unknown_model_id(monkeypatch): + import time + + from botocore.exceptions import ClientError + + from redisvl.utils.vectorize.bedrock import BedrockVectorizer + + monkeypatch.setattr(time, "sleep", lambda *a, **k: None) + + missing = ClientError( + {"Error": {"Code": "ResourceNotFoundException", "Message": "nope"}}, + "InvokeModel", + ) + mock_client = MagicMock() + mock_client.invoke_model.side_effect = missing + + with patch.object( + BedrockVectorizer, + "_initialize_client", + lambda self, *a, **k: setattr(self, "_client", mock_client), + ): + with pytest.raises(ValueError) as excinfo: + BedrockVectorizer(model="not-a-real-model") + + message = str(excinfo.value) + assert "not-a-real-model" in message + assert "AWS_REGION" in message + + +def test_huggingface_dim_probe_reports_local_model_load_failure(): + """A HuggingFace model that fails to load raises OSError inside the real + SentenceTransformer() construction, in _initialize_client() -- before + _set_model_dims() ever runs. This must be caught where it actually happens.""" + from redisvl.utils.vectorize.text.huggingface import HFTextVectorizer + + with patch( + "sentence_transformers.SentenceTransformer", + side_effect=OSError("no such file"), + ): + with pytest.raises(ValueError) as excinfo: + HFTextVectorizer(model="sentence-transformers/all-mpnet-base-v2") + + message = str(excinfo.value) + assert "sentence-transformers/all-mpnet-base-v2" in message + assert "downloaded" in message + + +# --------------------------------------------------------------------------- +# Cause-dispatch: _embed() is patched to raise the same shape of ValueError it +# really raises (built via _wrapped(), not a raw SDK exception), so these pin +# _set_model_dims()'s unwrap-and-dispatch logic in isolation. +# --------------------------------------------------------------------------- + + def test_azure_openai_dim_probe_names_the_deployment(): import openai from redisvl.utils.vectorize.text.azureopenai import AzureOpenAITextVectorizer - error = _openai_error(openai.NotFoundError, 404) + wrapped = _wrapped(_openai_error(openai.NotFoundError, 404)) with patch.object( AzureOpenAITextVectorizer, "_initialize_clients", lambda self, *a, **k: None ): - with patch.object(AzureOpenAITextVectorizer, "_embed", side_effect=error): + with patch.object(AzureOpenAITextVectorizer, "_embed", side_effect=wrapped): with pytest.raises(ValueError) as excinfo: AzureOpenAITextVectorizer(model="my-deployment") @@ -70,64 +207,93 @@ def test_azure_openai_dim_probe_names_the_deployment(): assert "deployment" in message -def test_bedrock_dim_probe_distinguishes_auth_from_bad_model_id(): - from botocore.exceptions import ClientError +def test_cohere_dim_probe_reports_unauthorized(): + import cohere - from redisvl.utils.vectorize.bedrock import BedrockVectorizer + from redisvl.utils.vectorize.text.cohere import CohereTextVectorizer - denied = ClientError( - {"Error": {"Code": "AccessDeniedException", "Message": "nope"}}, "InvokeModel" - ) + wrapped = _wrapped(cohere.UnauthorizedError("nope")) with patch.object( - BedrockVectorizer, "_initialize_client", lambda self, *a, **k: None + CohereTextVectorizer, "_initialize_client", lambda self, *a, **k: None ): - with patch.object(BedrockVectorizer, "_embed", side_effect=denied): + with patch.object(CohereTextVectorizer, "_embed", side_effect=wrapped): with pytest.raises(ValueError) as excinfo: - BedrockVectorizer(model="amazon.titan-embed-text-v2:0") + CohereTextVectorizer(model="embed-english-v3.0") message = str(excinfo.value) - assert "amazon.titan-embed-text-v2:0" in message - assert "bedrock:InvokeModel" in message + assert "embed-english-v3.0" in message + assert "COHERE_API_KEY" in message -def test_bedrock_dim_probe_reports_unknown_model_id(): - from botocore.exceptions import ClientError +def test_mistral_dim_probe_reports_sdk_error(): + from mistralai.models import SDKError - from redisvl.utils.vectorize.bedrock import BedrockVectorizer + from redisvl.utils.vectorize.text.mistral import MistralAITextVectorizer - missing = ClientError( - {"Error": {"Code": "ResourceNotFoundException", "Message": "nope"}}, - "InvokeModel", + wrapped = _wrapped( + SDKError( + "nope", + raw_response=httpx.Response( + 401, + request=httpx.Request("POST", "https://api.mistral.ai/v1/embeddings"), + ), + ) ) with patch.object( - BedrockVectorizer, "_initialize_client", lambda self, *a, **k: None + MistralAITextVectorizer, "_initialize_client", lambda self, *a, **k: None ): - with patch.object(BedrockVectorizer, "_embed", side_effect=missing): + with patch.object(MistralAITextVectorizer, "_embed", side_effect=wrapped): with pytest.raises(ValueError) as excinfo: - BedrockVectorizer(model="not-a-real-model") + MistralAITextVectorizer(model="mistral-embed") message = str(excinfo.value) - assert "not-a-real-model" in message - assert "AWS_REGION" in message + assert "mistral-embed" in message + assert "MISTRAL_API_KEY" in message -def test_huggingface_dim_probe_reports_local_model_load_failure(): - from redisvl.utils.vectorize.text.huggingface import HFTextVectorizer +def test_vertexai_dim_probe_reports_permission_denied(): + from google.api_core.exceptions import PermissionDenied + + from redisvl.utils.vectorize.vertexai import VertexAIVectorizer + + wrapped = _wrapped(PermissionDenied("nope")) with patch.object( - HFTextVectorizer, "_initialize_client", lambda self, *a, **k: None + VertexAIVectorizer, "_initialize_client", lambda self, *a, **k: None ): - with patch.object( - HFTextVectorizer, "_embed", side_effect=OSError("no such file") - ): + with patch.object(VertexAIVectorizer, "_embed", side_effect=wrapped): with pytest.raises(ValueError) as excinfo: - HFTextVectorizer(model="sentence-transformers/all-mpnet-base-v2") + VertexAIVectorizer(model="text-embedding-004") message = str(excinfo.value) - assert "sentence-transformers/all-mpnet-base-v2" in message - assert "downloaded" in message + assert "text-embedding-004" in message + assert "GOOGLE_APPLICATION_CREDENTIALS" in message + + +def test_voyageai_dim_probe_reports_authentication_error(): + import voyageai.error + + from redisvl.utils.vectorize.voyageai import VoyageAIVectorizer + + wrapped = _wrapped(voyageai.error.AuthenticationError("nope")) + + def _fake_init(self, *a, **k): + # _setup() reaches into self._client / self._aclient right after + # _initialize_client() returns (to grab .embed / .multimodal_embed), so + # the no-op stub has to leave both set rather than leaving them unset. + self._client = MagicMock() + self._aclient = MagicMock() + + with patch.object(VoyageAIVectorizer, "_initialize_client", _fake_init): + with patch.object(VoyageAIVectorizer, "_embed", side_effect=wrapped): + with pytest.raises(ValueError) as excinfo: + VoyageAIVectorizer(model="voyage-3") + + message = str(excinfo.value) + assert "voyage-3" in message + assert "VOYAGE_API_KEY" in message def test_unanticipated_errors_still_become_valueerror(): From 098e5e93b6cb66c4db15bac9d13b789f4480f4fc Mon Sep 17 00:00:00 2001 From: Aryan Pardeshi Date: Mon, 10 Aug 2026 20:29:38 +0530 Subject: [PATCH 3/5] fix: VoyageAI dim probe must also catch TypeError, not just ValueError/RetryError _embed_many() re-raises voyageai.error.InvalidRequestError as TypeError specifically so retry_if_not_exception_type(TypeError) skips retrying it -- a bad model id can never succeed regardless of attempt count. That means it reaches _set_model_dims() as a bare, unwrapped TypeError, never as ValueError or RetryError, so the unrecognized-model branch never fired. Caught by Cursor Bugbot on the second review pass. Verified: reverting the except tuple back to (ValueError, RetryError) makes the new test fail with the generic fallback message instead of the InvalidRequestError guidance. --- redisvl/utils/vectorize/voyageai.py | 10 +++++--- tests/unit/test_vectorizer_dim_errors.py | 29 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/redisvl/utils/vectorize/voyageai.py b/redisvl/utils/vectorize/voyageai.py index ca7872d4f..5c66c2120 100644 --- a/redisvl/utils/vectorize/voyageai.py +++ b/redisvl/utils/vectorize/voyageai.py @@ -236,13 +236,17 @@ def _set_model_dims(self) -> int: # Call the protected _embed method to avoid caching this test embedding embedding = self._embed("dimension check", input_type="document") return len(embedding) - except (ValueError, RetryError) as e: + except (ValueError, TypeError, RetryError) as e: # _embed()/_embed_many() are @retry-decorated with # retry_if_not_exception_type(TypeError), so the ValueError they # raise on a permanent failure (bad credentials, unknown model...) # is itself retried until tenacity gives up and raises RetryError. - # Unwrap that first, then unwrap the ValueError it wraps, to reach - # the real SDK exception either way. + # TypeError is different: _embed_many() re-raises + # voyageai.error.InvalidRequestError (an unrecognized model id, most + # commonly) as TypeError specifically so retry_if_not_exception_type + # skips it -- retrying a bad model id can't ever succeed. It arrives + # here as a plain TypeError, not wrapped in RetryError, so unwrap + # only if RetryError; TypeError already needs no unwrapping. root: BaseException = e if isinstance(root, RetryError): root = root.last_attempt.exception() or root diff --git a/tests/unit/test_vectorizer_dim_errors.py b/tests/unit/test_vectorizer_dim_errors.py index be7e55686..c918c3a37 100644 --- a/tests/unit/test_vectorizer_dim_errors.py +++ b/tests/unit/test_vectorizer_dim_errors.py @@ -296,6 +296,35 @@ def _fake_init(self, *a, **k): assert "VOYAGE_API_KEY" in message +def test_voyageai_dim_probe_reports_unrecognized_model_id(): + """VoyageAI's _embed_many() re-raises InvalidRequestError as TypeError -- + deliberately, so retry_if_not_exception_type(TypeError) skips retrying it, + since a bad model id can never succeed no matter how many attempts. That + means it reaches _set_model_dims() as a bare TypeError, never wrapped in + RetryError. This drives the real _embed_many() code (only the client's + .embed() call is stubbed) so it proves the TypeError path is actually + caught, not just that the dispatch logic handles it when handed one.""" + import voyageai.error + + from redisvl.utils.vectorize.voyageai import VoyageAIVectorizer + + bad_model = voyageai.error.InvalidRequestError("model not found") + mock_client = MagicMock() + mock_client.embed.side_effect = bad_model + + def _fake_init(self, *a, **k): + self._client = mock_client + self._aclient = mock_client + + with patch.object(VoyageAIVectorizer, "_initialize_client", _fake_init): + with pytest.raises(ValueError) as excinfo: + VoyageAIVectorizer(model="not-a-real-voyage-model") + + message = str(excinfo.value) + assert "not-a-real-voyage-model" in message + assert "not recognized" in message + + def test_unanticipated_errors_still_become_valueerror(): """The generic fallback must survive: no error may escape raw.""" from redisvl.utils.vectorize.text.openai import OpenAITextVectorizer From bc8205db6a22a94d0b22f607b7cda33a64464dcf Mon Sep 17 00:00:00 2001 From: Aryan Pardeshi Date: Tue, 11 Aug 2026 00:27:48 +0530 Subject: [PATCH 4/5] fix: guard cohere 5.x-only exception types, fix misleading HF OSError message --- redisvl/utils/vectorize/text/cohere.py | 17 +++++++++++++---- redisvl/utils/vectorize/text/huggingface.py | 7 +++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/redisvl/utils/vectorize/text/cohere.py b/redisvl/utils/vectorize/text/cohere.py index 1aa1b3012..3d8ea09b7 100644 --- a/redisvl/utils/vectorize/text/cohere.py +++ b/redisvl/utils/vectorize/text/cohere.py @@ -163,7 +163,11 @@ def _set_model_dims(self) -> int: ValueError: If embedding dimensions cannot be determined """ import cohere - from cohere.core.api_error import ApiError + + try: + from cohere.core.api_error import ApiError + except ImportError: + ApiError = None # cohere<5 has no core.api_error module try: # Call the protected _embed method to avoid caching this test embedding @@ -187,18 +191,23 @@ def _set_model_dims(self) -> int: raise ValueError( f"Unexpected response from the Cohere API: {str(cause)}" ) from e - if isinstance(cause, cohere.UnauthorizedError): + # UnauthorizedError/NotFoundError only exist in cohere>=5; the + # package still supports cohere>=4.44, so guard the attribute + # lookup rather than referencing them unconditionally. + unauthorized_error = getattr(cohere, "UnauthorizedError", None) + not_found_error = getattr(cohere, "NotFoundError", None) + if unauthorized_error and isinstance(cause, unauthorized_error): raise ValueError( f"Cohere rejected the credentials used while determining embedding " f"dimensions for model '{self.model}'. Check the api_key given in " f"api_config, or the COHERE_API_KEY environment variable: {str(cause)}" ) from e - if isinstance(cause, cohere.NotFoundError): + if not_found_error and isinstance(cause, not_found_error): raise ValueError( f"Cohere does not recognize the embedding model '{self.model}'. Check " f"the model name against the provider's current model list: {str(cause)}" ) from e - if isinstance(cause, ApiError): + if ApiError and isinstance(cause, ApiError): raise ValueError( f"The Cohere API returned an error while determining embedding " f"dimensions for model '{self.model}': {str(cause)}" diff --git a/redisvl/utils/vectorize/text/huggingface.py b/redisvl/utils/vectorize/text/huggingface.py index 6cbb95603..669d9e2a9 100644 --- a/redisvl/utils/vectorize/text/huggingface.py +++ b/redisvl/utils/vectorize/text/huggingface.py @@ -136,9 +136,12 @@ def _set_model_dims(self): except (KeyError, IndexError) as ke: raise ValueError(f"Empty response from the embedding model: {str(ke)}") except OSError as e: + # Unlike _initialize_client()'s OSError handler, this one fires + # after SentenceTransformer already loaded successfully -- the + # failure is in the dimension probe/encode call, not the load. raise ValueError( - f"Could not load the local embedding model '{self.model}'. Check the " - f"model name or path and that the model has been downloaded: {str(e)}" + f"The local embedding model '{self.model}' failed while determining its " + f"dimensions: {str(e)}" ) from e except RuntimeError as e: raise ValueError( From 5a1ebea647f0f9911ee0c056ce3cd82fc40c1fb2 Mon Sep 17 00:00:00 2001 From: Aryan Pardeshi Date: Thu, 13 Aug 2026 23:10:19 +0530 Subject: [PATCH 5/5] simplify vectorizer dim-probe error handling to a single chained except Per review on #680: the RetryError-unwrap-and-dispatch-by-SDK-type logic added a lot of bloated branching for limited lift. Collapse each provider's _set_model_dims() to one except Exception clause that wraps in a ValueError and chains the original exception with `from e`, which already surfaces the real cause in the traceback without the manual unwrapping. --- redisvl/utils/vectorize/bedrock.py | 58 +---- redisvl/utils/vectorize/text/azureopenai.py | 50 +--- redisvl/utils/vectorize/text/cohere.py | 54 +---- redisvl/utils/vectorize/text/mistral.py | 35 +-- redisvl/utils/vectorize/text/openai.py | 48 +--- redisvl/utils/vectorize/vertexai.py | 51 +--- redisvl/utils/vectorize/voyageai.py | 50 +--- tests/unit/test_vectorizer_dim_errors.py | 246 ++++++-------------- 8 files changed, 80 insertions(+), 512 deletions(-) diff --git a/redisvl/utils/vectorize/bedrock.py b/redisvl/utils/vectorize/bedrock.py index 9b8f598dc..c5f10a39a 100644 --- a/redisvl/utils/vectorize/bedrock.py +++ b/redisvl/utils/vectorize/bedrock.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any, Literal from pydantic import ConfigDict -from tenacity import RetryError, retry, stop_after_attempt, wait_random_exponential +from tenacity import retry, stop_after_attempt, wait_random_exponential from tenacity.retry import retry_if_not_exception_type if TYPE_CHECKING: @@ -189,68 +189,14 @@ def _set_model_dims(self) -> int: Raises: ValueError: If embedding dimensions cannot be determined """ - from botocore.exceptions import BotoCoreError, ClientError - try: # Call the protected _embed method to avoid caching this test embedding embedding = self._embed("dimension check") return len(embedding) - except (ValueError, RetryError) as e: - # _embed()/_embed_many() are @retry-decorated with - # retry_if_not_exception_type(TypeError), so the ValueError they - # raise on a permanent failure (bad credentials, unknown model...) - # is itself retried until tenacity gives up and raises RetryError. - # Unwrap that first, then unwrap the ValueError it wraps, to reach - # the real SDK exception either way. - root: BaseException = e - if isinstance(root, RetryError): - root = root.last_attempt.exception() or root - # _embed()/_embed_many() wrap the SDK's own exception in a ValueError, - # so dispatch on the wrapped cause instead of the wrapper -- catching - # the provider SDK's exception type here would never fire otherwise. - cause = root.__cause__ or root.__context__ or root - if isinstance(cause, (KeyError, IndexError)): - raise ValueError( - f"Unexpected response from the Bedrock API: {str(cause)}" - ) from e - if isinstance(cause, ClientError): - code = cause.response.get("Error", {}).get("Code", "") - if code in ( - "UnrecognizedClientException", - "AccessDeniedException", - "InvalidSignatureException", - "ExpiredTokenException", - ): - raise ValueError( - f"AWS rejected the credentials used while determining embedding " - f"dimensions for Bedrock model '{self.model}'. Check " - f"AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_REGION, and that " - f"the identity is allowed bedrock:InvokeModel: {str(cause)}" - ) from e - if code in ("ResourceNotFoundException", "ValidationException"): - raise ValueError( - f"Bedrock did not accept the model id '{self.model}' in this region. " - f"Check the model id and that model access is enabled for your " - f"account in AWS_REGION: {str(cause)}" - ) from e - raise ValueError( - f"The Bedrock API returned an error while determining embedding " - f"dimensions for model '{self.model}': {str(cause)}" - ) from e - if isinstance(cause, BotoCoreError): - raise ValueError( - f"Could not reach Bedrock while determining embedding dimensions for " - f"model '{self.model}'. Check network access, AWS_REGION and any proxy " - f"configuration: {str(cause)}" - ) from e - raise ValueError( - f"Error setting embedding model dimensions for Bedrock model " - f"'{self.model}': {str(e)}" - ) from e except Exception as e: # pylint: disable=broad-except raise ValueError( f"Error setting embedding model dimensions for Bedrock model " - f"'{self.model}': {str(e)}" + f"'{self.model}': {e}" ) from e @retry( diff --git a/redisvl/utils/vectorize/text/azureopenai.py b/redisvl/utils/vectorize/text/azureopenai.py index a69a15f1a..22a9659f7 100644 --- a/redisvl/utils/vectorize/text/azureopenai.py +++ b/redisvl/utils/vectorize/text/azureopenai.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any from pydantic import ConfigDict -from tenacity import RetryError, retry, stop_after_attempt, wait_random_exponential +from tenacity import retry, stop_after_attempt, wait_random_exponential from tenacity.retry import retry_if_not_exception_type if TYPE_CHECKING: @@ -204,60 +204,14 @@ def _set_model_dims(self) -> int: Raises: ValueError: If embedding dimensions cannot be determined """ - import openai - try: # Call the protected _embed method to avoid caching this test embedding embedding = self._embed("dimension check") return len(embedding) - except (ValueError, RetryError) as e: - # _embed()/_embed_many() are @retry-decorated with - # retry_if_not_exception_type(TypeError), so the ValueError they - # raise on a permanent failure (bad credentials, unknown model...) - # is itself retried until tenacity gives up and raises RetryError. - # Unwrap that first, then unwrap the ValueError it wraps, to reach - # the real SDK exception either way. - root: BaseException = e - if isinstance(root, RetryError): - root = root.last_attempt.exception() or root - # _embed()/_embed_many() wrap the SDK's own exception in a ValueError - # (or TypeError for VoyageAI's InvalidRequestError below), so dispatch - # on the wrapped cause instead of the wrapper -- catching the provider - # SDK's exception type here would never fire otherwise. - cause = root.__cause__ or root.__context__ or root - if isinstance(cause, (KeyError, IndexError)): - raise ValueError( - f"Unexpected response from the AzureOpenAI API: {str(cause)}" - ) from e - if isinstance( - cause, (openai.AuthenticationError, openai.PermissionDeniedError) - ): - raise ValueError( - f"Azure OpenAI rejected the credentials used while determining embedding " - f"dimensions for deployment '{self.model}'. Check the api_key and " - f"azure_endpoint given in api_config, or the AZURE_OPENAI_API_KEY and " - f"AZURE_OPENAI_ENDPOINT environment variables: {str(cause)}" - ) from e - if isinstance(cause, openai.NotFoundError): - raise ValueError( - f"Azure OpenAI does not recognize the deployment '{self.model}'. Azure " - f"addresses models by deployment name rather than model name -- check " - f"that the deployment exists in this resource: {str(cause)}" - ) from e - if isinstance(cause, openai.APIConnectionError): - raise ValueError( - f"Could not reach the Azure OpenAI endpoint while determining embedding " - f"dimensions for deployment '{self.model}'. Check the endpoint URL, " - f"network access and any proxy configuration: {str(cause)}" - ) from e - raise ValueError( - f"Error setting embedding model dimensions for Azure OpenAI deployment " - f"'{self.model}': {str(e)}" - ) from e except Exception as e: # pylint: disable=broad-except raise ValueError( f"Error setting embedding model dimensions for Azure OpenAI deployment " - f"'{self.model}': {str(e)}" + f"'{self.model}': {e}" ) from e @deprecated_argument("text", "content") diff --git a/redisvl/utils/vectorize/text/cohere.py b/redisvl/utils/vectorize/text/cohere.py index 3d8ea09b7..dec724822 100644 --- a/redisvl/utils/vectorize/text/cohere.py +++ b/redisvl/utils/vectorize/text/cohere.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any from pydantic import ConfigDict -from tenacity import RetryError, retry, stop_after_attempt, wait_random_exponential +from tenacity import retry, stop_after_attempt, wait_random_exponential from tenacity.retry import retry_if_not_exception_type if TYPE_CHECKING: @@ -162,64 +162,14 @@ def _set_model_dims(self) -> int: Raises: ValueError: If embedding dimensions cannot be determined """ - import cohere - - try: - from cohere.core.api_error import ApiError - except ImportError: - ApiError = None # cohere<5 has no core.api_error module - try: # Call the protected _embed method to avoid caching this test embedding embedding = self._embed("dimension check", input_type="search_document") return len(embedding) - except (ValueError, RetryError) as e: - # _embed()/_embed_many() are @retry-decorated with - # retry_if_not_exception_type(TypeError), so the ValueError they - # raise on a permanent failure (bad credentials, unknown model...) - # is itself retried until tenacity gives up and raises RetryError. - # Unwrap that first, then unwrap the ValueError it wraps, to reach - # the real SDK exception either way. - root: BaseException = e - if isinstance(root, RetryError): - root = root.last_attempt.exception() or root - # _embed()/_embed_many() wrap the SDK's own exception in a ValueError, - # so dispatch on the wrapped cause instead of the wrapper -- catching - # the provider SDK's exception type here would never fire otherwise. - cause = root.__cause__ or root.__context__ or root - if isinstance(cause, (KeyError, IndexError)): - raise ValueError( - f"Unexpected response from the Cohere API: {str(cause)}" - ) from e - # UnauthorizedError/NotFoundError only exist in cohere>=5; the - # package still supports cohere>=4.44, so guard the attribute - # lookup rather than referencing them unconditionally. - unauthorized_error = getattr(cohere, "UnauthorizedError", None) - not_found_error = getattr(cohere, "NotFoundError", None) - if unauthorized_error and isinstance(cause, unauthorized_error): - raise ValueError( - f"Cohere rejected the credentials used while determining embedding " - f"dimensions for model '{self.model}'. Check the api_key given in " - f"api_config, or the COHERE_API_KEY environment variable: {str(cause)}" - ) from e - if not_found_error and isinstance(cause, not_found_error): - raise ValueError( - f"Cohere does not recognize the embedding model '{self.model}'. Check " - f"the model name against the provider's current model list: {str(cause)}" - ) from e - if ApiError and isinstance(cause, ApiError): - raise ValueError( - f"The Cohere API returned an error while determining embedding " - f"dimensions for model '{self.model}': {str(cause)}" - ) from e - raise ValueError( - f"Error setting embedding model dimensions for Cohere model " - f"'{self.model}': {str(e)}" - ) from e except Exception as e: # pylint: disable=broad-except raise ValueError( f"Error setting embedding model dimensions for Cohere model " - f"'{self.model}': {str(e)}" + f"'{self.model}': {e}" ) from e def _get_cohere_embedding_type(self, dtype: str) -> list[str]: diff --git a/redisvl/utils/vectorize/text/mistral.py b/redisvl/utils/vectorize/text/mistral.py index 56b05260f..bd74ab9e6 100644 --- a/redisvl/utils/vectorize/text/mistral.py +++ b/redisvl/utils/vectorize/text/mistral.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any from pydantic import ConfigDict -from tenacity import RetryError, retry, stop_after_attempt, wait_random_exponential +from tenacity import retry, stop_after_attempt, wait_random_exponential from tenacity.retry import retry_if_not_exception_type if TYPE_CHECKING: @@ -153,45 +153,14 @@ def _set_model_dims(self) -> int: Raises: ValueError: If embedding dimensions cannot be determined """ - from mistralai.models import SDKError - try: # Call the protected _embed method to avoid caching this test embedding embedding = self._embed("dimension check") return len(embedding) - except (ValueError, RetryError) as e: - # _embed()/_embed_many() are @retry-decorated with - # retry_if_not_exception_type(TypeError), so the ValueError they - # raise on a permanent failure (bad credentials, unknown model...) - # is itself retried until tenacity gives up and raises RetryError. - # Unwrap that first, then unwrap the ValueError it wraps, to reach - # the real SDK exception either way. - root: BaseException = e - if isinstance(root, RetryError): - root = root.last_attempt.exception() or root - # _embed()/_embed_many() wrap the SDK's own exception in a ValueError, - # so dispatch on the wrapped cause instead of the wrapper -- catching - # the provider SDK's exception type here would never fire otherwise. - cause = root.__cause__ or root.__context__ or root - if isinstance(cause, (KeyError, IndexError)): - raise ValueError( - f"Unexpected response from the MISTRAL API: {str(cause)}" - ) from e - if isinstance(cause, SDKError): - raise ValueError( - f"The Mistral API returned an error while determining embedding " - f"dimensions for model '{self.model}'. If this is an authentication " - f"failure check the api_key given in api_config or the MISTRAL_API_KEY " - f"environment variable; if it is a 404 check the model name: {str(cause)}" - ) from e - raise ValueError( - f"Error setting embedding model dimensions for Mistral model " - f"'{self.model}': {str(e)}" - ) from e except Exception as e: # pylint: disable=broad-except raise ValueError( f"Error setting embedding model dimensions for Mistral model " - f"'{self.model}': {str(e)}" + f"'{self.model}': {e}" ) from e @deprecated_argument("text", "content") diff --git a/redisvl/utils/vectorize/text/openai.py b/redisvl/utils/vectorize/text/openai.py index c8a55b5ef..6bcd0d3d2 100644 --- a/redisvl/utils/vectorize/text/openai.py +++ b/redisvl/utils/vectorize/text/openai.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any from pydantic import ConfigDict -from tenacity import RetryError, retry, stop_after_attempt, wait_random_exponential +from tenacity import retry, stop_after_attempt, wait_random_exponential from tenacity.retry import retry_if_not_exception_type if TYPE_CHECKING: @@ -152,58 +152,14 @@ def _set_model_dims(self) -> int: Raises: ValueError: If embedding dimensions cannot be determined """ - import openai - try: # Use the parent embed() method which handles caching embedding = self._embed("dimension check") return len(embedding) - except (ValueError, RetryError) as e: - # _embed()/_embed_many() are @retry-decorated with - # retry_if_not_exception_type(TypeError), so the ValueError they - # raise on a permanent failure (bad credentials, unknown model...) - # is itself retried until tenacity gives up and raises RetryError. - # Unwrap that first, then unwrap the ValueError it wraps, to reach - # the real SDK exception either way. - root: BaseException = e - if isinstance(root, RetryError): - root = root.last_attempt.exception() or root - # _embed()/_embed_many() wrap the SDK's own exception in a ValueError - # (or TypeError for VoyageAI's InvalidRequestError below), so dispatch - # on the wrapped cause instead of the wrapper -- catching the provider - # SDK's exception type here would never fire otherwise. - cause = root.__cause__ or root.__context__ or root - if isinstance(cause, (KeyError, IndexError)): - raise ValueError( - f"Unexpected response from the OpenAI API: {str(cause)}" - ) from e - if isinstance( - cause, (openai.AuthenticationError, openai.PermissionDeniedError) - ): - raise ValueError( - f"OpenAI rejected the credentials used while determining embedding " - f"dimensions for model '{self.model}'. Check the api_key given in " - f"api_config, or the OPENAI_API_KEY environment variable: {str(cause)}" - ) from e - if isinstance(cause, openai.NotFoundError): - raise ValueError( - f"OpenAI does not recognize the embedding model '{self.model}'. Check " - f"the model name against the provider's current model list: {str(cause)}" - ) from e - if isinstance(cause, openai.APIConnectionError): - raise ValueError( - f"Could not reach the OpenAI API while determining embedding dimensions " - f"for model '{self.model}'. Check network access and any proxy " - f"configuration: {str(cause)}" - ) from e - raise ValueError( - f"Error setting embedding model dimensions for OpenAI model " - f"'{self.model}': {str(e)}" - ) from e except Exception as e: # pylint: disable=broad-except raise ValueError( f"Error setting embedding model dimensions for OpenAI model " - f"'{self.model}': {str(e)}" + f"'{self.model}': {e}" ) from e @deprecated_argument("text", "content") diff --git a/redisvl/utils/vectorize/vertexai.py b/redisvl/utils/vectorize/vertexai.py index 9781eaf9c..81598ec36 100644 --- a/redisvl/utils/vectorize/vertexai.py +++ b/redisvl/utils/vectorize/vertexai.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any from pydantic import ConfigDict -from tenacity import RetryError, retry, stop_after_attempt, wait_random_exponential +from tenacity import retry, stop_after_attempt, wait_random_exponential from tenacity.retry import retry_if_not_exception_type if TYPE_CHECKING: @@ -228,61 +228,14 @@ def _set_model_dims(self) -> int: Raises: ValueError: If embedding dimensions cannot be determined """ - from google.api_core.exceptions import ( - GoogleAPICallError, - NotFound, - PermissionDenied, - Unauthenticated, - ) - try: # Call the protected _embed method to avoid caching this test embedding embedding = self._embed("dimension check") return len(embedding) - except (ValueError, RetryError) as e: - # _embed()/_embed_many() are @retry-decorated with - # retry_if_not_exception_type(TypeError), so the ValueError they - # raise on a permanent failure (bad credentials, unknown model...) - # is itself retried until tenacity gives up and raises RetryError. - # Unwrap that first, then unwrap the ValueError it wraps, to reach - # the real SDK exception either way. - root: BaseException = e - if isinstance(root, RetryError): - root = root.last_attempt.exception() or root - # _embed()/_embed_many() wrap the SDK's own exception in a ValueError, - # so dispatch on the wrapped cause instead of the wrapper -- catching - # the provider SDK's exception type here would never fire otherwise. - cause = root.__cause__ or root.__context__ or root - if isinstance(cause, (KeyError, IndexError)): - raise ValueError( - f"Unexpected response from the VertexAI API: {str(cause)}" - ) from e - if isinstance(cause, (PermissionDenied, Unauthenticated)): - raise ValueError( - f"Google Cloud rejected the credentials used while determining embedding " - f"dimensions for model '{self.model}'. Check " - f"GOOGLE_APPLICATION_CREDENTIALS and that the service account holds the " - f"Vertex AI User role: {str(cause)}" - ) from e - if isinstance(cause, NotFound): - raise ValueError( - f"Vertex AI could not find the embedding model '{self.model}' in the " - f"configured project and location. Check the model name, GCP_PROJECT_ID " - f"and GCP_LOCATION: {str(cause)}" - ) from e - if isinstance(cause, GoogleAPICallError): - raise ValueError( - f"The Vertex AI API returned an error while determining embedding " - f"dimensions for model '{self.model}': {str(cause)}" - ) from e - raise ValueError( - f"Error setting embedding model dimensions for Vertex AI model " - f"'{self.model}': {str(e)}" - ) from e except Exception as e: # pylint: disable=broad-except raise ValueError( f"Error setting embedding model dimensions for Vertex AI model " - f"'{self.model}': {str(e)}" + f"'{self.model}': {e}" ) from e @retry( diff --git a/redisvl/utils/vectorize/voyageai.py b/redisvl/utils/vectorize/voyageai.py index 5c66c2120..7bb534057 100644 --- a/redisvl/utils/vectorize/voyageai.py +++ b/redisvl/utils/vectorize/voyageai.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any from pydantic import ConfigDict -from tenacity import RetryError, retry, stop_after_attempt, wait_random_exponential +from tenacity import retry, stop_after_attempt, wait_random_exponential from tenacity.retry import retry_if_not_exception_type if TYPE_CHECKING: @@ -230,60 +230,14 @@ def _set_model_dims(self) -> int: Raises: ValueError: If embedding dimensions cannot be determined """ - import voyageai.error - try: # Call the protected _embed method to avoid caching this test embedding embedding = self._embed("dimension check", input_type="document") return len(embedding) - except (ValueError, TypeError, RetryError) as e: - # _embed()/_embed_many() are @retry-decorated with - # retry_if_not_exception_type(TypeError), so the ValueError they - # raise on a permanent failure (bad credentials, unknown model...) - # is itself retried until tenacity gives up and raises RetryError. - # TypeError is different: _embed_many() re-raises - # voyageai.error.InvalidRequestError (an unrecognized model id, most - # commonly) as TypeError specifically so retry_if_not_exception_type - # skips it -- retrying a bad model id can't ever succeed. It arrives - # here as a plain TypeError, not wrapped in RetryError, so unwrap - # only if RetryError; TypeError already needs no unwrapping. - root: BaseException = e - if isinstance(root, RetryError): - root = root.last_attempt.exception() or root - # _embed()/_embed_many() wrap the SDK's own exception in a ValueError, - # so dispatch on the wrapped cause instead of the wrapper -- catching - # the provider SDK's exception type here would never fire otherwise. - cause = root.__cause__ or root.__context__ or root - if isinstance(cause, (KeyError, IndexError)): - raise ValueError( - f"Unexpected response from the VoyageAI API: {str(cause)}" - ) from e - if isinstance(cause, voyageai.error.AuthenticationError): - raise ValueError( - f"VoyageAI rejected the credentials used while determining embedding " - f"dimensions for model '{self.model}'. Check the api_key given in " - f"api_config, or the VOYAGE_API_KEY environment variable: {str(cause)}" - ) from e - if isinstance(cause, voyageai.error.InvalidRequestError): - raise ValueError( - f"VoyageAI rejected the request used to determine embedding dimensions " - f"for model '{self.model}'. This usually means the model name is not " - f"recognized: {str(cause)}" - ) from e - if isinstance(cause, voyageai.error.APIConnectionError): - raise ValueError( - f"Could not reach the VoyageAI API while determining embedding " - f"dimensions for model '{self.model}'. Check network access and any " - f"proxy configuration: {str(cause)}" - ) from e - raise ValueError( - f"Error setting embedding model dimensions for VoyageAI model " - f"'{self.model}': {str(e)}" - ) from e except Exception as e: # pylint: disable=broad-except raise ValueError( f"Error setting embedding model dimensions for VoyageAI model " - f"'{self.model}': {str(e)}" + f"'{self.model}': {e}" ) from e def _get_batch_size(self) -> int: diff --git a/tests/unit/test_vectorizer_dim_errors.py b/tests/unit/test_vectorizer_dim_errors.py index c918c3a37..dbefb80ba 100644 --- a/tests/unit/test_vectorizer_dim_errors.py +++ b/tests/unit/test_vectorizer_dim_errors.py @@ -1,28 +1,10 @@ -"""Provider-specific error messages from vectorizer ``_set_model_dims()``. +"""Errors from vectorizer ``_set_model_dims()`` become an actionable ``ValueError``. Each vectorizer probes its provider with a throwaway embedding call to learn the -model's dimensionality. When that probe fails, the resulting ``ValueError`` should -name the provider, the model, and what the caller can do about it -- not just -restate the SDK's own message. - -``_embed()``/``_embed_many()`` on every provider already catch the SDK's own -exception and re-wrap it in a generic ``ValueError`` before ``_set_model_dims()`` -ever sees it. That means ``_set_model_dims()`` cannot simply catch the SDK's -exception type directly -- it has to unwrap the ``ValueError`` it receives and -dispatch on ``__cause__``/``__context__`` instead. A test that patches ``_embed`` -with ``side_effect=`` skips that wrapping entirely and would pass -even if the dispatch logic were broken, since it hands ``_set_model_dims`` an -exception shape production code never produces. - -So this file mixes two kinds of test: - -- True end-to-end tests (OpenAI, Bedrock, HuggingFace) that patch only the - network client / model-loading call, so the vectorizer's real ``_embed()`` / - ``_initialize_client()`` code runs and performs the real wrapping. -- Cause-dispatch tests for the remaining providers, which hand ``_embed`` a - ``ValueError`` built the same way ``_embed`` really builds one -- raised while - handling the SDK's exception, so ``__context__`` is set by Python's normal - implicit exception chaining, not fabricated by the test. +model's dimensionality. When that probe fails, ``_set_model_dims()`` wraps +whatever it catches in a ``ValueError`` that names the provider and the model, +chained with ``from e`` so the original exception (SDK error, retry exhaustion, +etc.) stays visible in the traceback rather than being swallowed. """ from unittest.mock import MagicMock, patch @@ -38,42 +20,14 @@ def _openai_error(cls, status): return cls("boom", response=response, body=None) -def _wrapped(cause: BaseException, message: str = "wrapped") -> ValueError: - """Build a ValueError whose __context__ is `cause`. - - This mirrors exactly what `_embed()`/`_embed_many()` produce: they catch the - SDK's exception and raise a generic ValueError while still handling it, which - is what makes Python set __context__ via implicit chaining. Constructing the - ValueError outside of an active `except cause` block would leave __context__ - unset, so `cause` is actually raised and caught here rather than merely - referenced. - """ - try: - raise cause - except type(cause): - try: - raise ValueError(message) - except ValueError as wrapped_error: - return wrapped_error - - # --------------------------------------------------------------------------- # End-to-end: the real _embed()/_initialize_client() code runs. # --------------------------------------------------------------------------- -@pytest.mark.parametrize( - "status, error_name, expected", - [ - (401, "AuthenticationError", "OPENAI_API_KEY"), - (404, "NotFoundError", "does not recognize the embedding model"), - ], -) -def test_openai_dim_probe_reports_provider_and_remediation( - monkeypatch, status, error_name, expected -): +def test_openai_dim_probe_names_the_model_and_chains_the_cause(monkeypatch): """OpenAI's real client.embeddings.create() raises, _embed() wraps it, and - _set_model_dims() must still recover the real cause and report on it.""" + _set_model_dims() must still report the model and preserve the cause.""" import time import openai @@ -86,7 +40,7 @@ def test_openai_dim_probe_reports_provider_and_remediation( # itself isn't what this test is checking. monkeypatch.setattr(time, "sleep", lambda *a, **k: None) - error = _openai_error(getattr(openai, error_name), status) + error = _openai_error(openai.AuthenticationError, 401) mock_client = MagicMock() mock_client.embeddings.create.side_effect = error @@ -100,12 +54,12 @@ def test_openai_dim_probe_reports_provider_and_remediation( message = str(excinfo.value) assert "text-embedding-3-small" in message - assert expected in message + assert excinfo.value.__cause__ is not None -def test_bedrock_dim_probe_distinguishes_auth_from_bad_model_id(monkeypatch): +def test_bedrock_dim_probe_names_the_model_and_chains_the_cause(monkeypatch): """Bedrock's real client.invoke_model() raises a ClientError, _embed() wraps - it, and _set_model_dims() must still branch on the AWS error code.""" + it, and _set_model_dims() must still report the model and preserve the cause.""" import time from botocore.exceptions import ClientError @@ -130,36 +84,7 @@ def test_bedrock_dim_probe_distinguishes_auth_from_bad_model_id(monkeypatch): message = str(excinfo.value) assert "amazon.titan-embed-text-v2:0" in message - assert "bedrock:InvokeModel" in message - - -def test_bedrock_dim_probe_reports_unknown_model_id(monkeypatch): - import time - - from botocore.exceptions import ClientError - - from redisvl.utils.vectorize.bedrock import BedrockVectorizer - - monkeypatch.setattr(time, "sleep", lambda *a, **k: None) - - missing = ClientError( - {"Error": {"Code": "ResourceNotFoundException", "Message": "nope"}}, - "InvokeModel", - ) - mock_client = MagicMock() - mock_client.invoke_model.side_effect = missing - - with patch.object( - BedrockVectorizer, - "_initialize_client", - lambda self, *a, **k: setattr(self, "_client", mock_client), - ): - with pytest.raises(ValueError) as excinfo: - BedrockVectorizer(model="not-a-real-model") - - message = str(excinfo.value) - assert "not-a-real-model" in message - assert "AWS_REGION" in message + assert excinfo.value.__cause__ is not None def test_huggingface_dim_probe_reports_local_model_load_failure(): @@ -181,103 +106,65 @@ def test_huggingface_dim_probe_reports_local_model_load_failure(): # --------------------------------------------------------------------------- -# Cause-dispatch: _embed() is patched to raise the same shape of ValueError it -# really raises (built via _wrapped(), not a raw SDK exception), so these pin -# _set_model_dims()'s unwrap-and-dispatch logic in isolation. +# Wrap-and-chain: _embed() is patched to raise directly, pinning that +# _set_model_dims() names the provider/model and chains the real cause via +# `from e` rather than losing it. # --------------------------------------------------------------------------- -def test_azure_openai_dim_probe_names_the_deployment(): - import openai - - from redisvl.utils.vectorize.text.azureopenai import AzureOpenAITextVectorizer - - wrapped = _wrapped(_openai_error(openai.NotFoundError, 404)) - - with patch.object( - AzureOpenAITextVectorizer, "_initialize_clients", lambda self, *a, **k: None - ): - with patch.object(AzureOpenAITextVectorizer, "_embed", side_effect=wrapped): - with pytest.raises(ValueError) as excinfo: - AzureOpenAITextVectorizer(model="my-deployment") - - message = str(excinfo.value) - assert "my-deployment" in message - # Azure addresses models by deployment name; the message must say so. - assert "deployment" in message - - -def test_cohere_dim_probe_reports_unauthorized(): - import cohere - - from redisvl.utils.vectorize.text.cohere import CohereTextVectorizer - - wrapped = _wrapped(cohere.UnauthorizedError("nope")) - - with patch.object( - CohereTextVectorizer, "_initialize_client", lambda self, *a, **k: None - ): - with patch.object(CohereTextVectorizer, "_embed", side_effect=wrapped): - with pytest.raises(ValueError) as excinfo: - CohereTextVectorizer(model="embed-english-v3.0") - - message = str(excinfo.value) - assert "embed-english-v3.0" in message - assert "COHERE_API_KEY" in message - - -def test_mistral_dim_probe_reports_sdk_error(): - from mistralai.models import SDKError - - from redisvl.utils.vectorize.text.mistral import MistralAITextVectorizer - - wrapped = _wrapped( - SDKError( - "nope", - raw_response=httpx.Response( - 401, - request=httpx.Request("POST", "https://api.mistral.ai/v1/embeddings"), - ), - ) - ) - - with patch.object( - MistralAITextVectorizer, "_initialize_client", lambda self, *a, **k: None - ): - with patch.object(MistralAITextVectorizer, "_embed", side_effect=wrapped): - with pytest.raises(ValueError) as excinfo: - MistralAITextVectorizer(model="mistral-embed") - - message = str(excinfo.value) - assert "mistral-embed" in message - assert "MISTRAL_API_KEY" in message - - -def test_vertexai_dim_probe_reports_permission_denied(): - from google.api_core.exceptions import PermissionDenied +@pytest.mark.parametrize( + "vectorizer_path, class_name, init_method, model", + [ + ( + "redisvl.utils.vectorize.text.azureopenai", + "AzureOpenAITextVectorizer", + "_initialize_clients", + "my-deployment", + ), + ( + "redisvl.utils.vectorize.text.cohere", + "CohereTextVectorizer", + "_initialize_client", + "embed-english-v3.0", + ), + ( + "redisvl.utils.vectorize.text.mistral", + "MistralAITextVectorizer", + "_initialize_client", + "mistral-embed", + ), + ( + "redisvl.utils.vectorize.vertexai", + "VertexAIVectorizer", + "_initialize_client", + "text-embedding-004", + ), + ], +) +def test_dim_probe_names_the_model_and_chains_the_cause( + vectorizer_path, class_name, init_method, model +): + import importlib - from redisvl.utils.vectorize.vertexai import VertexAIVectorizer + module = importlib.import_module(vectorizer_path) + vectorizer_cls = getattr(module, class_name) - wrapped = _wrapped(PermissionDenied("nope")) + cause = RuntimeError("boom from the SDK") - with patch.object( - VertexAIVectorizer, "_initialize_client", lambda self, *a, **k: None - ): - with patch.object(VertexAIVectorizer, "_embed", side_effect=wrapped): + with patch.object(vectorizer_cls, init_method, lambda self, *a, **k: None): + with patch.object(vectorizer_cls, "_embed", side_effect=cause): with pytest.raises(ValueError) as excinfo: - VertexAIVectorizer(model="text-embedding-004") + vectorizer_cls(model=model) message = str(excinfo.value) - assert "text-embedding-004" in message - assert "GOOGLE_APPLICATION_CREDENTIALS" in message + assert model in message + assert excinfo.value.__cause__ is cause -def test_voyageai_dim_probe_reports_authentication_error(): - import voyageai.error - +def test_voyageai_dim_probe_names_the_model_and_chains_the_cause(): from redisvl.utils.vectorize.voyageai import VoyageAIVectorizer - wrapped = _wrapped(voyageai.error.AuthenticationError("nope")) + cause = RuntimeError("boom from the SDK") def _fake_init(self, *a, **k): # _setup() reaches into self._client / self._aclient right after @@ -287,23 +174,22 @@ def _fake_init(self, *a, **k): self._aclient = MagicMock() with patch.object(VoyageAIVectorizer, "_initialize_client", _fake_init): - with patch.object(VoyageAIVectorizer, "_embed", side_effect=wrapped): + with patch.object(VoyageAIVectorizer, "_embed", side_effect=cause): with pytest.raises(ValueError) as excinfo: VoyageAIVectorizer(model="voyage-3") message = str(excinfo.value) assert "voyage-3" in message - assert "VOYAGE_API_KEY" in message + assert excinfo.value.__cause__ is cause -def test_voyageai_dim_probe_reports_unrecognized_model_id(): +def test_voyageai_dim_probe_catches_bad_model_id_type_error(): """VoyageAI's _embed_many() re-raises InvalidRequestError as TypeError -- deliberately, so retry_if_not_exception_type(TypeError) skips retrying it, - since a bad model id can never succeed no matter how many attempts. That - means it reaches _set_model_dims() as a bare TypeError, never wrapped in - RetryError. This drives the real _embed_many() code (only the client's - .embed() call is stubbed) so it proves the TypeError path is actually - caught, not just that the dispatch logic handles it when handed one.""" + since a bad model id can never succeed no matter how many attempts. This + drives the real _embed_many() code (only the client's .embed() call is + stubbed) to prove the TypeError path is still caught by the generic + except Exception clause.""" import voyageai.error from redisvl.utils.vectorize.voyageai import VoyageAIVectorizer @@ -322,7 +208,7 @@ def _fake_init(self, *a, **k): message = str(excinfo.value) assert "not-a-real-voyage-model" in message - assert "not recognized" in message + assert excinfo.value.__cause__ is not None def test_unanticipated_errors_still_become_valueerror():