diff --git a/litellm/llms/mongodb/common_utils.py b/litellm/llms/mongodb/common_utils.py index bf3bf953772..0978368e874 100644 --- a/litellm/llms/mongodb/common_utils.py +++ b/litellm/llms/mongodb/common_utils.py @@ -1,10 +1,10 @@ -"""Shared helpers for MongoDB Atlas integrations. +"""Shared helpers for MongoDB integrations. pymongo ships in the optional ``mongodb`` extra, so every import of it is deferred to call time and raises an actionable error when it is absent. Clients are cached per connection because building one costs an SRV lookup, a -TLS handshake and topology discovery: measured at ~890ms against Atlas versus +TLS handshake and topology discovery: measured at ~890ms against a remote deployment versus ~80ms on a warm client, so a client per search would dominate query latency. """ @@ -141,15 +141,16 @@ def reset_client_cache() -> None: _AUTHENTICATION_FAILED_CODE: Final = 18 _UNAUTHORIZED_CODE: Final = 13 -# Atlas reports a rejected user as code 8000 "AtlasError", not 18, so only the message is reliable +# Atlas reports a rejected user as code 8000 "AtlasError" where a self-managed mongod reports 18 _AUTHENTICATION_MESSAGE_MARKERS: Final = ("bad auth", "authentication failed", "not authorized") _RESOLUTION_TIMEOUT_MARKERS: Final = ("resolution lifetime expired", "dns operation timed out") _UNKNOWN_HOSTNAME_MARKERS: Final = ("dns query name does not exist", "name or service not known") +_CREDENTIAL_ESCAPING_MARKERS: Final = ("must be escaped according to rfc 3986", "bad database name") def _index_hint(index_name: str, database: str, collection: str) -> str: return ( - f"No queryable Atlas Vector Search index named '{index_name}' was found on " + f"No queryable MongoDB Vector Search index named '{index_name}' was found on " f"'{database}.{collection}'. Confirm the index exists on that exact collection, that its " "status is READY rather than still building, and that the vector store id matches the index name." ) @@ -168,7 +169,7 @@ def missing_index_error(index_name: str, database: str, collection: str) -> BadR def index_not_ready_error(index_name: str, database: str, collection: str, status: str) -> BadRequestError: return config_error( - f"The Atlas Vector Search index '{index_name}' on '{database}.{collection}' is not queryable " + f"The MongoDB Vector Search index '{index_name}' on '{database}.{collection}' is not queryable " f"yet; its status is {status}. Searches against it return no results until the build finishes." ) @@ -194,8 +195,9 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll if isinstance(error, ServerSelectionTimeoutError): return timeout_error( "Could not reach the MongoDB deployment before the timeout. On Atlas this is usually the " - "project's IP access list not containing this host, or a paused cluster; it can also be an " - f"unresolvable hostname. Driver detail: {error}" + "project's IP access list not containing this host, or a paused cluster. On a self-managed " + "deployment it is usually the host or port in the URI, or a firewall between this process " + f"and mongod. Either way it can also be an unresolvable hostname. Driver detail: {error}" ) # ExecutionTimeout subclasses OperationFailure, so it has to be matched before it if isinstance(error, (NetworkTimeout, ExecutionTimeout)): @@ -208,8 +210,9 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll if isinstance(error, ConnectionFailure): return config_error( f"The connection to '{database}.{collection}' was refused or dropped. On Atlas this is " - "usually a connection string with no username and password, or a TLS failure. Confirm " - f"the URI is the one Atlas shows under Connect, Drivers. Driver detail: {error}" + "usually a connection string with no username and password, or a TLS failure, so confirm " + "the URI is the one Atlas shows under Connect, Drivers. On a self-managed deployment, check " + f"that mongod is listening on the host and port in the URI. Driver detail: {error}" ) if isinstance(error, OperationFailure): code: Final = error.code @@ -223,13 +226,13 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll ) if "dimension" in detail: return config_error( - "The query embedding does not match the vector dimensions the Atlas index was built for. " + "The query embedding does not match the vector dimensions the index was built for. " "litellm_embedding_model must be the same model that produced the stored vectors. " f"Driver detail: {error}" ) if "is not indexed as vector" in detail: return config_error( - "mongodb_embedding_field names a field the Atlas Vector Search index does not cover. " + "mongodb_embedding_field names a field the MongoDB Vector Search index does not cover. " f"It must match the 'path' the index '{index_name}' was created on. Driver detail: {error}" ) if "index" in detail and ("not found" in detail or "does not exist" in detail or "unknown" in detail): @@ -248,19 +251,28 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll ) if any(marker in configuration_detail for marker in _UNKNOWN_HOSTNAME_MARKERS): return config_error( - "The cluster hostname in mongodb_connection_string does not exist in DNS. Check the " - f"cluster name against the URI Atlas shows under Connect, Drivers. Driver detail: {error}" + "The hostname in mongodb_connection_string does not exist in DNS. On Atlas, check the " + "cluster name against the URI shown under Connect, Drivers. On a self-managed deployment, " + f"check that the hostname resolves from this process. Driver detail: {error}" + ) + if any(marker in configuration_detail for marker in _CREDENTIAL_ESCAPING_MARKERS): + return config_error( + "mongodb_connection_string could not be parsed. A username or password containing " + "'@', '/', ':' or '%' has to be percent-encoded per RFC 3986, so 'p@ss/word' becomes " + "'p%40ss%2Fword'. If the credentials are already encoded, check the database name in " + f"the URI path instead. Driver detail: {error}" ) return config_error( f"mongodb_connection_string is not a usable MongoDB connection string. Driver detail: {error}" ) if isinstance(error, InvalidOperation): return config_error(f"The MongoDB client was already closed or is unusable. Driver detail: {error}") - # pymongo's URI parser raises a plain ValueError, not a PyMongoError, for a password holding an - # unescaped '/', which would otherwise reach the caller as a 500 + # pymongo raises a plain ValueError, not a PyMongoError, for an unusable port, which an unescaped + # ':' in a password also produces, and which would otherwise reach the caller as a 500 if isinstance(error, ValueError): return config_error( - "mongodb_connection_string could not be parsed. A username or password containing " - f"'@', '/', ':' or '%' has to be percent-encoded per RFC 3986. Driver detail: {error}" + "The host and port in mongodb_connection_string could not be parsed. If the port is a " + "number between 0 and 65535, the cause is usually an unescaped ':' in the password, which " + f"has to be percent-encoded per RFC 3986 as '%3A'. Driver detail: {error}" ) return error diff --git a/litellm/llms/mongodb/vector_stores/transformation.py b/litellm/llms/mongodb/vector_stores/transformation.py index 5e59fd30f1b..571061d39a2 100644 --- a/litellm/llms/mongodb/vector_stores/transformation.py +++ b/litellm/llms/mongodb/vector_stores/transformation.py @@ -1,11 +1,12 @@ -"""MongoDB Atlas vector store provider. +"""MongoDB vector store provider, for Atlas and self-managed deployments alike. -Atlas Vector Search has no HTTP query API (the Data API and HTTPS Endpoints are +MongoDB Vector Search has no HTTP query API (the Data API and HTTPS Endpoints are end-of-life), so this config extends BaseDirectVectorStoreConfig and runs the ``$vectorSearch`` aggregation itself through pymongo instead of shaping an httpx -request. +request. mongod serves that stage identically whether mongot runs under Atlas or +beside a self-managed deployment, so one code path covers both. -``vector_store_id`` is the Atlas Search index name, matching the Valkey provider +``vector_store_id`` is the search index name, matching the Valkey provider where the id names the index; the database and collection it covers come from litellm_params. """ @@ -60,7 +61,7 @@ MAX_QUERY_CHARACTERS: Final = 32_000 _EMPTY_EMBEDDING_CONFIG: Final = MappingProxyType({}) _SEARCH_ONLY_MESSAGE: Final = ( - "MongoDB vector store is search-only. Create the collection and its Atlas Vector Search " + "MongoDB vector store is search-only. Create the collection and its MongoDB Vector Search " "index in MongoDB directly, then register it here by index name." ) @@ -101,7 +102,8 @@ class _MongoDBSearchParams(BaseModel): if not self.mongodb_connection_string: raise config_error( "mongodb_connection_string is required in litellm_params for the MongoDB vector store. " - "Example: mongodb+srv://:@.mongodb.net" + "Example: mongodb+srv://:@.mongodb.net for Atlas, or " + "mongodb://:@:27017 for a self-managed deployment" ) scheme: Final = self.mongodb_connection_string.split("://", 1)[0].lower() if scheme not in ("mongodb", "mongodb+srv"): @@ -234,12 +236,12 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): if vector_store_search_optional_params.get("filters") is not None: raise config_error( "MongoDB vector store does not support the filters parameter yet. " - "Restrict the collection or the Atlas Vector Search index definition instead." + "Restrict the collection or the MongoDB Vector Search index definition instead." ) if vector_store_search_optional_params.get("ranking_options") is not None: raise config_error( "MongoDB vector store does not support the ranking_options parameter yet. " - "Every result already carries the Atlas vectorSearchScore, so filter or re-rank " + "Every result already carries the vectorSearchScore, so filter or re-rank " "on that rather than having the threshold silently ignored." ) if vector_store_search_optional_params.get("rewrite_query") is not None: @@ -296,7 +298,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): def _raise_for_missing_text_field( cls, documents: Sequence[Mapping[str, object]], text_field: str, database: str, collection: str ) -> None: - """Atlas happily matches vectors in documents that carry no text at all, so a mistyped + """$vectorSearch happily matches documents that carry no text at all, so a mistyped mongodb_text_field returns well-scored results whose content is empty and feeds an empty context to the model. Every matched document lacking the field is the misconfiguration.""" if documents and all(cls._field_value(document, text_field) is None for document in documents): @@ -322,7 +324,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): def _raise_for_unusable_index( catalogue: Sequence[Mapping[str, object]], index_name: str, database: str, collection: str ) -> None: - """An empty result set is ambiguous: Atlas returns zero documents both for a query that + """An empty result set is ambiguous: mongod returns zero documents both for a query that genuinely matched nothing and for a missing database, collection or index. Only the second is a misconfiguration, so the index catalogue decides which one happened.""" if not catalogue: diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py index d60504c31e5..4e57755076f 100644 --- a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -788,8 +788,8 @@ class TestErrorTranslation: assert "refused or dropped" not in str(translated) def test_an_unescaped_password_character_is_a_400_not_a_500(self): - """pymongo's URI parser raises a plain ValueError, not a PyMongoError, when a password - holds an unescaped '/'. That is a routine mistake and it must not be a 500.""" + """pymongo's URI parser raises a plain ValueError, not a PyMongoError, for an unusable port, + which is also what an unescaped ':' in a password produces. It must not be a 500.""" translated = self._translate(ValueError("Port contains non-digit characters")) assert isinstance(translated, BadRequestError) @@ -895,7 +895,7 @@ class TestEmptyResultsAreDisambiguated: def test_a_missing_index_becomes_an_error_rather_than_an_empty_page(self): config, _, collection = _config(documents=[], search_indexes=[]) - with pytest.raises(BadRequestError, match="No queryable Atlas Vector Search index"): + with pytest.raises(BadRequestError, match="No queryable MongoDB Vector Search index"): _search(config) assert collection.listed_indexes == [INDEX] @@ -934,7 +934,7 @@ class TestEmptyResultsAreDisambiguated: async def test_async_missing_index_becomes_an_error_rather_than_an_empty_page(self): config, _, collection = _async_config(documents=[], search_indexes=[]) - with pytest.raises(BadRequestError, match="No queryable Atlas Vector Search index"): + with pytest.raises(BadRequestError, match="No queryable MongoDB Vector Search index"): await _asearch(config) assert collection.listed_indexes == [INDEX] @@ -1202,3 +1202,163 @@ class TestClientConstructionFailures: with pytest.raises(BadRequestError, match="not a usable MongoDB connection string"): await _asearch(config) + + +class TestSelfManagedDeploymentsAreFirstClass: + """mongod serves $vectorSearch identically whether mongot runs under Atlas or beside a + self-managed deployment, so an operator without an Atlas account has to be able to act on + every message. Guidance that only names Atlas remedies sends them looking for an IP access + list and a paused cluster that do not exist in their deployment.""" + + def _config_that_fails_to_connect(self, error): + def factory(_key): + raise error + + return MongoDBVectorStoreConfig( + embedding_fn=FakeEmbeddingFn([0.1, 0.2, 0.3]), sync_client_factory=factory + ) + + def test_a_plain_mongodb_uri_without_srv_or_credentials_is_accepted(self): + params = _MongoDBSearchParams.model_validate( + {**BASE_PARAMS, "mongodb_connection_string": "mongodb://mongod.internal:27017"} + ) + + assert params.require_connection_string() == "mongodb://mongod.internal:27017" + + def test_an_unreachable_deployment_names_a_self_managed_remedy(self): + from pymongo.errors import ServerSelectionTimeoutError + + config = self._config_that_fails_to_connect(ServerSelectionTimeoutError("connection refused")) + + with pytest.raises(Timeout) as excinfo: + _search(config) + + assert "self-managed" in str(excinfo.value) + assert "host or port" in str(excinfo.value) + + def test_a_refused_connection_names_a_self_managed_remedy(self): + from pymongo.errors import ConnectionFailure + + config = self._config_that_fails_to_connect(ConnectionFailure("connection closed")) + + with pytest.raises(BadRequestError) as excinfo: + _search(config) + + assert "self-managed" in str(excinfo.value) + assert "mongod is listening" in str(excinfo.value) + + def test_an_unresolvable_hostname_names_a_self_managed_remedy(self): + from pymongo.errors import ConfigurationError + + config = self._config_that_fails_to_connect(ConfigurationError("The DNS query name does not exist")) + + with pytest.raises(BadRequestError) as excinfo: + _search(config) + + assert "self-managed" in str(excinfo.value) + + def test_the_missing_index_message_does_not_claim_atlas(self): + message = str(missing_index_error(INDEX, "sample_mflix", "embedded_movies")) + + assert "MongoDB Vector Search index" in message + assert "Atlas" not in message + + def test_the_not_ready_message_does_not_claim_atlas(self): + message = str(index_not_ready_error(INDEX, "sample_mflix", "embedded_movies", "PENDING")) + + assert "MongoDB Vector Search index" in message + assert "Atlas" not in message + + def test_the_search_only_refusal_does_not_claim_atlas(self): + config = MongoDBVectorStoreConfig() + + with pytest.raises(BadRequestError) as excinfo: + config.transform_create_vector_store_request({}, api_base="") + + assert "Atlas" not in str(excinfo.value) + + def test_a_dimension_mismatch_does_not_claim_atlas(self): + from pymongo.errors import OperationFailure + + error = OperationFailure("vector field is indexed with 128 dimensions but queried with 256") + translated = translate_mongo_error(error, index_name=INDEX, database="db", collection="c") + + assert "Atlas" not in str(translated) + assert "dimensions the index was built for" in str(translated) + + def test_an_uncovered_embedding_field_does_not_claim_atlas(self): + from pymongo.errors import OperationFailure + + error = OperationFailure("embedding is not indexed as vector") + translated = translate_mongo_error(error, index_name=INDEX, database="db", collection="c") + + assert "MongoDB Vector Search index does not cover" in str(translated) + assert "Atlas" not in str(translated) + + def test_a_self_managed_auth_failure_is_still_recognised_by_code_18(self): + from pymongo.errors import OperationFailure + + error = OperationFailure("Authentication failed.", code=18, details={"code": 18}) + translated = translate_mongo_error(error, index_name=INDEX, database="db", collection="c") + + assert isinstance(translated, BadRequestError) + assert "rejected the credentials" in str(translated) + + +class TestUnescapedCredentialsAreDiagnosed: + """Self-managed deployments usually carry a generated password, so '@', '/', ':' and '%' in one + are routine. pymongo reports those as a port, a database name or an RFC 3986 complaint, none of + which points the operator at their password, so each has to be named for what it is. The errors + here come from pymongo's real parser rather than a synthetic stand-in.""" + + @staticmethod + def _real_parse_error(uri): + from pymongo import MongoClient + + try: + MongoClient(uri, serverSelectionTimeoutMS=1) + except Exception as e: + return e + raise AssertionError(f"expected {uri!r} to fail parsing") + + def _translated(self, uri): + return translate_mongo_error( + self._real_parse_error(uri), index_name=INDEX, database="db", collection="c" + ) + + @pytest.mark.parametrize( + "uri", + [ + "mongodb://user:pa@ss@host:27017/", + "mongodb://user:pa:ss@host:27017/", + "mongodb://user:pa%ss@host:27017/", + "mongodb://user@x:pw@host:27017/", + ], + ) + def test_rfc_3986_complaints_tell_the_operator_to_encode_the_password(self, uri): + translated = self._translated(uri) + + assert isinstance(translated, BadRequestError) + assert "percent-encoded per RFC 3986" in str(translated) + + @pytest.mark.parametrize( + "uri", + ["mongodb://user:pa/ss@host:27017/", "mongodb://user/x:pw@host:27017/"], + ) + def test_a_slash_in_the_credentials_is_not_reported_as_a_database_name(self, uri): + translated = self._translated(uri) + + assert isinstance(translated, BadRequestError) + assert "percent-encoded per RFC 3986" in str(translated) + + def test_an_unusable_port_names_the_host_and_port_not_the_database(self): + translated = self._translated("mongodb://host:99999/") + + assert isinstance(translated, BadRequestError) + assert "host and port" in str(translated) + + def test_a_genuinely_bad_database_name_still_mentions_the_uri_path(self): + translated = self._translated("mongodb://host:27017/has space") + + assert isinstance(translated, BadRequestError) + assert "database name in the URI path" in str(translated)