From 1fed1029e01c84f447f52119ef96757a62ae69b0 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 11:12:51 -0700 Subject: [PATCH] fix(vector_stores): report a MongoDB text field that no matched document has Atlas matches on the vector alone, so a mistyped mongodb_text_field still returns confidently scored results whose content is empty, and the model is handed an empty context with nothing to explain it. When every matched document lacks the field the search now says which setting to fix; a sparse document among others that do have it, and a document whose text is genuinely the empty string, both still come back normally. Unrecognised mongodb_* parameters are named too. The params model has to ignore unrelated keys because litellm_params carries plenty of them, which turned a mistyped mongodb_collection into "mongodb_collection is required" pointing the reader at a key they can see they have set. --- .../mongodb/vector_stores/transformation.py | 54 ++++++++++++++++-- .../test_mongodb_transformation.py | 57 ++++++++++++++++++- 2 files changed, 103 insertions(+), 8 deletions(-) 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"})