diff --git a/litellm/llms/mongodb/vector_stores/transformation.py b/litellm/llms/mongodb/vector_stores/transformation.py index 2570e368990..8b8ca5188cf 100644 --- a/litellm/llms/mongodb/vector_stores/transformation.py +++ b/litellm/llms/mongodb/vector_stores/transformation.py @@ -128,6 +128,12 @@ class _MongoDBSearchParams(BaseModel): return self.mongodb_collection +_MONGODB_PARAM_PREFIX: Final = "mongodb_" +_KNOWN_MONGODB_PARAMS: Final = frozenset( + name for name in _MongoDBSearchParams.model_fields if name.startswith(_MONGODB_PARAM_PREFIX) +) + + class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): def __init__( self, @@ -142,6 +148,22 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): self.sync_client_factory = sync_client_factory if sync_client_factory is not None else get_sync_client self.async_client_factory = async_client_factory if async_client_factory is not None else get_async_client + @staticmethod + def _reject_unknown_params(litellm_params: Mapping[str, object]) -> None: + """The params model ignores unrelated keys because litellm_params carries plenty of them, + which would otherwise turn a mistyped mongodb_collection into 'mongodb_collection is + required' pointing at a key the reader can see they have set.""" + unknown: Final = sorted( + key + for key in litellm_params + if key.startswith(_MONGODB_PARAM_PREFIX) and key not in _KNOWN_MONGODB_PARAMS + ) + if unknown: + raise config_error( + f"Unrecognised MongoDB vector store parameter(s): {', '.join(unknown)}. " + f"Supported: {', '.join(sorted(_KNOWN_MONGODB_PARAMS))}." + ) + @staticmethod def _query_text(query: str | Sequence[str]) -> str: text: Final = query if isinstance(query, str) else " ".join(query) @@ -219,20 +241,22 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): ] @staticmethod - def _field_value(document: Mapping[str, object], dotted_path: str) -> str: + def _field_value(document: Mapping[str, object], dotted_path: str) -> str | None: + """None means the path is absent from the document, which is what separates a + mistyped mongodb_text_field from a document whose text is genuinely empty.""" current: object = document for segment in dotted_path.split("."): - if not isinstance(current, Mapping): - return "" - current = current.get(segment) - return "" if current is None else str(current) + if not isinstance(current, Mapping) or segment not in current: + return None + current = current[segment] + return None if current is None else str(current) @classmethod def _to_result(cls, document: Mapping[str, object], text_field: str) -> VectorStoreSearchResult: document_id: Final = document.get("_id") identifier: Final = None if document_id is None else str(document_id) content: Final = [ # mutable-ok: VectorStoreSearchResult declares a list of content parts - VectorStoreResultContent(text=cls._field_value(document, text_field), type="text") + VectorStoreResultContent(text=cls._field_value(document, text_field) or "", type="text") ] raw_score: Final = document.get(SCORE_FIELD_NAME) return VectorStoreSearchResult( @@ -242,6 +266,20 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): filename=identifier, ) + @classmethod + 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 + 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): + raise config_error( + f"None of the {len(documents)} matched documents in '{database}.{collection}' has a " + f"'{text_field}' field, so every result would carry empty text. Set mongodb_text_field " + "to the field holding the readable text; it accepts a dotted path such as metadata.body." + ) + @classmethod def _to_response( cls, documents: Sequence[Mapping[str, object]], query_text: str, text_field: str @@ -284,6 +322,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): litellm_params: Mapping[str, object], timeout: float | httpx.Timeout | None = None, ) -> VectorStoreSearchResponse: + self._reject_unknown_params(litellm_params) params: Final = _MongoDBSearchParams.model_validate(litellm_params) query_text: Final = self._query_text(query) key: Final = self._client_key(params, timeout) @@ -315,6 +354,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): e, index_name=vector_store_id, database=database, collection=collection ) from e self._raise_for_unusable_index(catalogue, vector_store_id, database, collection) + self._raise_for_missing_text_field(documents, params.text_field, database, collection) return self._to_response(documents, query_text, params.text_field) async def aexecute_search_vector_store_request( @@ -326,6 +366,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): litellm_params: Mapping[str, object], timeout: float | httpx.Timeout | None = None, ) -> VectorStoreSearchResponse: + self._reject_unknown_params(litellm_params) params: Final = _MongoDBSearchParams.model_validate(litellm_params) query_text: Final = self._query_text(query) key: Final = self._client_key(params, timeout) @@ -359,6 +400,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): e, index_name=vector_store_id, database=database, collection=collection ) from e self._raise_for_unusable_index(catalogue, vector_store_id, database, collection) + self._raise_for_missing_text_field(documents, params.text_field, database, collection) return self._to_response(documents, query_text, params.text_field) def transform_create_vector_store_request( 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 9e2bccc3f26..68437487176 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 @@ -275,12 +275,30 @@ def test_response_reads_a_dotted_text_field_path(): assert response["data"][0]["content"][0]["text"] == "nested text" -def test_response_tolerates_a_document_missing_the_text_field(): - config, _, _ = _config(documents=[{"_id": 1, "score": 0.5}]) +def test_response_tolerates_a_sparse_document_missing_the_text_field(): + config, _, _ = _config(documents=[{"_id": 1, "score": 0.5}, {"_id": 2, "text": "has text", "score": 0.4}]) response = _search(config) assert response["data"][0]["content"][0]["text"] == "" + assert response["data"][1]["content"][0]["text"] == "has text" + + +def test_a_present_but_empty_text_field_is_not_treated_as_a_misconfiguration(): + config, _, _ = _config(documents=[{"_id": 1, "text": "", "score": 0.5}]) + + response = _search(config) + + assert response["data"][0]["content"][0]["text"] == "" + + +def test_matches_that_all_lack_the_text_field_name_the_setting_to_fix(): + """Atlas matches on the vector, so a mistyped mongodb_text_field returns confidently + scored results whose content is empty and hands the model an empty context.""" + config, _, _ = _config(documents=[{"_id": 1, "score": 0.9}, {"_id": 2, "score": 0.8}]) + + with pytest.raises(BadRequestError, match="mongodb_text_field"): + _search(config) def test_response_tolerates_a_document_missing_a_score(): @@ -930,3 +948,38 @@ def test_a_rejected_search_that_is_not_an_auth_failure_keeps_the_generic_message translated = translate_mongo_error(error, index_name="idx", database="db", collection="coll") assert "mongodb_connection_string" not in str(translated) + + +class TestUnrecognisedParameters: + """litellm_params carries plenty of keys this provider does not own, so the params model has + to ignore extras. That turns a mistyped mongodb_collection into 'mongodb_collection is + required', pointing the reader at a key they can see they have set.""" + + def test_a_mistyped_parameter_is_named(self): + config, _, _ = _config() + + with pytest.raises(BadRequestError, match="mongodb_collectoin"): + _search(config, litellm_params={"mongodb_collectoin": "embedded_movies"}) + + def test_the_supported_names_are_listed(self): + config, _, _ = _config() + + with pytest.raises(BadRequestError, match="mongodb_connection_string"): + _search(config, litellm_params={"mongodb_databse": "sample_mflix"}) + + def test_unrelated_litellm_params_are_still_ignored(self): + config, _, _ = _config(documents=[{"_id": 1, "text": "hit", "score": 0.9}]) + + response = _search( + config, + litellm_params={"use_litellm_proxy": False, "use_in_pass_through": False, "vector_store_id": "x"}, + ) + + assert len(response["data"]) == 1 + + @pytest.mark.asyncio + async def test_the_async_path_rejects_them_too(self): + config, _, _ = _async_config() + + with pytest.raises(BadRequestError, match="mongodb_collectoin"): + await _asearch(config, litellm_params={"mongodb_collectoin": "embedded_movies"})