diff --git a/.github/workflows/_test-unit-base.yml b/.github/workflows/_test-unit-base.yml index d2cc0aa6d8d..6f6822a975b 100644 --- a/.github/workflows/_test-unit-base.yml +++ b/.github/workflows/_test-unit-base.yml @@ -116,7 +116,7 @@ jobs: if: steps.changes.outputs.decision != 'skip' timeout-minutes: 8 run: | - .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml + .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra mongodb uv run --no-sync python -c 'import os, sys; print(sys.version); assert f"{sys.version_info.major}.{sys.version_info.minor}" == os.environ["UV_PYTHON"]' - name: Cache Prisma binaries diff --git a/litellm/litellm_core_utils/sensitive_data_masker.py b/litellm/litellm_core_utils/sensitive_data_masker.py index 15b2c879224..22dd4170963 100644 --- a/litellm/litellm_core_utils/sensitive_data_masker.py +++ b/litellm/litellm_core_utils/sensitive_data_masker.py @@ -1,4 +1,5 @@ from collections.abc import Mapping, Sequence +from collections.abc import Set as AbstractSet from typing import Any, Final from pydantic import BaseModel @@ -6,38 +7,45 @@ from pydantic import BaseModel from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH, DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER from litellm.litellm_core_utils.secret_redaction import REDACTED +_DEFAULT_SENSITIVE_PATTERNS: Final = frozenset( + ( + "password", + "secret", + "key", + "token", + "auth", + "authorization", + "credential", + # Plural form: Vertex uses ``vertex_credentials``; segment-exact + # matching otherwise misses it because "credential" != "credentials". + "credentials", + "access", + "private", + "certificate", + "fingerprint", + "tenancy", + ) +) + class SensitiveDataMasker: def __init__( self, - sensitive_patterns: set[str] | None = None, - non_sensitive_overrides: set[str] | None = None, + sensitive_patterns: AbstractSet[str] | None = None, + non_sensitive_overrides: AbstractSet[str] | None = None, visible_prefix: int = 4, visible_suffix: int = 4, mask_char: str = "*", mask_short_values: bool = True, + extra_sensitive_patterns: AbstractSet[str] | None = None, ): - self.sensitive_patterns = sensitive_patterns or { - "password", - "secret", - "key", - "token", - "auth", - "authorization", - "credential", - # Plural form: Vertex uses ``vertex_credentials``; segment-exact - # matching otherwise misses it because "credential" != "credentials". - "credentials", - "access", - "private", - "certificate", - "fingerprint", - "tenancy", - } + self.sensitive_patterns = (sensitive_patterns or _DEFAULT_SENSITIVE_PATTERNS) | ( + extra_sensitive_patterns or frozenset() + ) # If any key segment matches one of these, the key is not considered sensitive # even if it also matches a sensitive pattern. For example, "input_cost_per_token" # contains "token" but "cost" overrides that — it's a pricing field, not a secret. - self.non_sensitive_overrides = non_sensitive_overrides or {"cost"} + self.non_sensitive_overrides = non_sensitive_overrides or frozenset(("cost",)) self.visible_prefix = visible_prefix self.visible_suffix = visible_suffix diff --git a/litellm/llms/mongodb/__init__.py b/litellm/llms/mongodb/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/mongodb/common_utils.py b/litellm/llms/mongodb/common_utils.py new file mode 100644 index 00000000000..02c0b359407 --- /dev/null +++ b/litellm/llms/mongodb/common_utils.py @@ -0,0 +1,303 @@ +"""Shared helpers for the MongoDB integrations. pymongo lives in the optional ``mongodb`` extra, +so every import of it is deferred to call time.""" + +import asyncio +import threading +import weakref +from asyncio import AbstractEventLoop +from collections import OrderedDict +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import TYPE_CHECKING, Final, TypeAlias, TypeVar + +from litellm.exceptions import BadRequestError, ServiceUnavailableError, Timeout + +if TYPE_CHECKING: + from pymongo import AsyncMongoClient, MongoClient + +PYMONGO_INSTALL_HINT: Final = ( + "The MongoDB vector store requires the 'pymongo' package. " + "Run 'pip install litellm[mongodb]' (or 'pip install pymongo') to install it." +) + +MONGODB_PROVIDER: Final = "mongodb" + + +def config_error(message: str) -> BadRequestError: + """400 rather than the 500 a bare ValueError becomes once litellm.exception_type wraps it.""" + return BadRequestError(message=message, model=None, llm_provider=MONGODB_PROVIDER) + + +def timeout_error(message: str) -> Timeout: + return Timeout(message=message, model=None, llm_provider=MONGODB_PROVIDER) + + +def unavailable_error(message: str) -> ServiceUnavailableError: + """litellm only retries 408, 409, 429 and 5xx, so a 400 here would make a failover permanent.""" + return ServiceUnavailableError(message=message, model=None, llm_provider=MONGODB_PROVIDER) + + +DEFAULT_CONNECT_TIMEOUT_MS: Final = 10_000 +DEFAULT_SOCKET_TIMEOUT_MS: Final = 30_000 +DEFAULT_SERVER_SELECTION_TIMEOUT_MS: Final = 10_000 + +_MAX_CACHED_CLIENTS: Final = 32 + +_APP_NAME: Final = "litellm" + + +@dataclass(frozen=True, slots=True) +class MongoClientKey: + connection_string: str + connect_timeout_ms: int + socket_timeout_ms: int + server_selection_timeout_ms: int + + +SyncClientFactory: TypeAlias = Callable[..., "MongoClient"] +AsyncClientFactory: TypeAlias = Callable[..., "AsyncMongoClient"] + +_K = TypeVar("_K") +_V = TypeVar("_V") + +_AsyncClientCacheKey: TypeAlias = tuple[MongoClientKey, int] +# CPython recycles id() aggressively, so the id alone would hand a new loop a closed loop's client +_AsyncClientEntry: TypeAlias = tuple["weakref.ref[AbstractEventLoop]", "AsyncMongoClient"] + +_SyncClientCache: TypeAlias = "OrderedDict[MongoClientKey, MongoClient]" +_AsyncClientCache: TypeAlias = "OrderedDict[_AsyncClientCacheKey, _AsyncClientEntry]" + +_sync_clients: Final[_SyncClientCache] = OrderedDict() # mutable-ok: process-level client cache +_async_clients: Final[_AsyncClientCache] = OrderedDict() # mutable-ok: same cache, per loop +# async searches reach the sync client through executor threads, so both caches are shared state +_cache_lock: Final = threading.Lock() + + +def _store_bounded(cache: "OrderedDict[_K, _V]", cache_key: "_K", value: "_V") -> None: + """Eviction only drops this cache's reference; an in-flight search keeps its client alive.""" + with _cache_lock: + cache[cache_key] = value # mutable-ok: an LRU cache is mutable state by definition + cache.move_to_end(cache_key) + while len(cache) > _MAX_CACHED_CLIENTS: + cache.popitem(last=False) + + +def _mark_used(cache: "OrderedDict[_K, _V]", cache_key: "_K") -> None: + with _cache_lock: + if cache_key in cache: + cache.move_to_end(cache_key) + + +def import_sync_mongo_client() -> "type[MongoClient]": + try: + from pymongo import MongoClient as SyncMongoClient + except ImportError as e: + raise config_error(PYMONGO_INSTALL_HINT) from e + return SyncMongoClient + + +def import_async_mongo_client() -> "type[AsyncMongoClient]": + try: + from pymongo import AsyncMongoClient as AsyncMongoClientClass + except ImportError as e: + raise config_error(PYMONGO_INSTALL_HINT) from e + return AsyncMongoClientClass + + +def _client_kwargs(key: MongoClientKey) -> Mapping[str, object]: + return MappingProxyType( + { + "connectTimeoutMS": key.connect_timeout_ms, + "socketTimeoutMS": key.socket_timeout_ms, + "serverSelectionTimeoutMS": key.server_selection_timeout_ms, + "appname": _APP_NAME, + } + ) + + +def get_sync_client(key: MongoClientKey, client_class: SyncClientFactory | None = None) -> "MongoClient": + cached: Final = _sync_clients.get(key) + if cached is not None: + _mark_used(_sync_clients, key) + return cached + build: Final = client_class if client_class is not None else import_sync_mongo_client() + client: Final = build(key.connection_string, **_client_kwargs(key)) + _store_bounded(_sync_clients, key, client) + return client + + +def _purge_dead_loops() -> None: + """A cached client holds its loop alive, so a closed loop's entry would pin that client and its + sockets for the life of the process.""" + with _cache_lock: + for stale in tuple( + cache_key + for cache_key, (loop_ref, _) in _async_clients.items() + if (cached_loop := loop_ref()) is None or cached_loop.is_closed() + ): + del _async_clients[stale] + + +def get_async_client(key: MongoClientKey, client_class: AsyncClientFactory | None = None) -> "AsyncMongoClient": + """Async clients bind to the loop that created them, so the cache is keyed per loop.""" + loop: Final = asyncio.get_running_loop() + loop_key: Final = (key, id(loop)) + cached: Final = _async_clients.get(loop_key) + if cached is not None and cached[0]() is loop: + _mark_used(_async_clients, loop_key) + return cached[1] + _purge_dead_loops() + build: Final = client_class if client_class is not None else import_async_mongo_client() + client: Final = build(key.connection_string, **_client_kwargs(key)) + _store_bounded(_async_clients, loop_key, (weakref.ref(loop), client)) + return client + + +def reset_client_cache() -> None: + with _cache_lock: + _sync_clients.clear() + _async_clients.clear() + + +_AUTHENTICATION_FAILED_CODE: Final = 18 +_UNAUTHORIZED_CODE: Final = 13 +# 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 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." + ) + + +def missing_index_error(index_name: str, database: str, collection: str) -> BadRequestError: + """$vectorSearch against a missing index, database or collection returns zero documents rather + than failing, so an empty result set is checked against the catalogue and reported as this.""" + return config_error( + f"{_index_hint(index_name, database, collection)} A vector search against a database, " + "collection or index that does not exist returns no results rather than an error, so this " + "was reported as an empty result set by MongoDB." + ) + + +def index_not_ready_error(index_name: str, database: str, collection: str, status: str) -> BadRequestError: + return config_error( + 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." + ) + + +def translate_mongo_error(error: Exception, index_name: str, database: str, collection: str) -> Exception: + """Returns the exception to raise, so callers keep the driver error as ``__cause__``.""" + try: + from pymongo.errors import ( + ConfigurationError, + ConnectionFailure, + ExecutionTimeout, + InvalidOperation, + NetworkTimeout, + OperationFailure, + ServerSelectionTimeoutError, + ) + except ImportError: + return error + + 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. 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)): + return timeout_error( + f"The MongoDB vector search against '{database}.{collection}' timed out before returning. " + f"Driver detail: {error}" + ) + # ServerSelectionTimeoutError and NetworkTimeout also subclass ConnectionFailure, so this only + # sees what those branches left + if isinstance(error, ConnectionFailure): + return unavailable_error( + f"The connection to '{database}.{collection}' was dropped or refused. That is usually a " + "replica set failover or a restarted node, so the search is worth retrying. If it keeps " + "happening: on Atlas the usual cause is 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 that mongod is listening on the host and port in the URI. " + f"Driver detail: {error}" + ) + if isinstance(error, OperationFailure): + code: Final = error.code + detail: Final = str(error).lower() + if code in (_AUTHENTICATION_FAILED_CODE, _UNAUTHORIZED_CODE) or any( + marker in detail for marker in _AUTHENTICATION_MESSAGE_MARKERS + ): + return config_error( + "MongoDB rejected the credentials in mongodb_connection_string, or the database user " + f"lacks read access to '{database}.{collection}'. Driver detail: {error.details}" + ) + if "dimension" in detail: + return config_error( + "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 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): + return config_error(f"{_index_hint(index_name, database, collection)} Driver detail: {error}") + return config_error( + f"MongoDB rejected the vector search against '{database}.{collection}' using index " + f"'{index_name}'. Driver detail: {error}" + ) + if isinstance(error, ConfigurationError): + configuration_detail: Final = str(error).lower() + if any(marker in configuration_detail for marker in _RESOLUTION_TIMEOUT_MARKERS): + return timeout_error( + "The DNS lookup for the cluster in mongodb_connection_string did not finish in time. " + "A mongodb+srv:// URI needs an SRV lookup before any connection is attempted, so this " + f"is DNS or the configured timeout, not MongoDB. Driver detail: {error}" + ) + if any(marker in configuration_detail for marker in _UNKNOWN_HOSTNAME_MARKERS): + return config_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}") + # An unreadable tlsCAFile or tlsCertificateKeyFile raises OSError, not a PyMongoError + if isinstance(error, OSError) and error.filename: + return config_error( + f"'{error.filename}', named by a TLS option in mongodb_connection_string, could not be read. " + "Check that tlsCAFile and tlsCertificateKeyFile point at files this process can open; inside " + f"a container that is the path in the container, not on the host. Driver detail: {error}" + ) + # pymongo raises a plain ValueError, not a PyMongoError, for an unusable port + if isinstance(error, ValueError): + return config_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/__init__.py b/litellm/llms/mongodb/vector_stores/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/mongodb/vector_stores/transformation.py b/litellm/llms/mongodb/vector_stores/transformation.py new file mode 100644 index 00000000000..3382c931c96 --- /dev/null +++ b/litellm/llms/mongodb/vector_stores/transformation.py @@ -0,0 +1,431 @@ +"""MongoDB Vector Search has no HTTP query API, so this is a direct provider that runs the +``$vectorSearch`` aggregation through pymongo. ``vector_store_id`` is the search index name.""" + +from collections.abc import Callable, Mapping, Sequence +from types import MappingProxyType +from typing import TYPE_CHECKING, Final, NoReturn + +import httpx +from pydantic import BaseModel, ConfigDict + +from litellm.llms.base_llm.vector_store.transformation import ( + BaseDirectVectorStoreConfig, + LiteLLMVectorStoreEmbeddingExecutor, + VectorStoreEmbeddingExecutor, +) +from litellm.llms.mongodb.common_utils import ( + DEFAULT_CONNECT_TIMEOUT_MS, + DEFAULT_SERVER_SELECTION_TIMEOUT_MS, + DEFAULT_SOCKET_TIMEOUT_MS, + MongoClientKey, + config_error, + get_async_client, + get_sync_client, + index_not_ready_error, + missing_index_error, + translate_mongo_error, +) +from litellm.types.utils import EmbeddingResponse +from litellm.types.vector_stores import ( + VectorStoreCreateOptionalRequestParams, + VectorStoreResultContent, + VectorStoreSearchOptionalRequestParams, + VectorStoreSearchResponse, + VectorStoreSearchResult, +) + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + +DEFAULT_EMBEDDING_FIELD_NAME: Final = "embedding" +DEFAULT_TEXT_FIELD_NAME: Final = "text" +SCORE_FIELD_NAME: Final = "score" + +DEFAULT_MAX_NUM_RESULTS: Final = 10 +MIN_MAX_NUM_RESULTS: Final = 1 +MAX_MAX_NUM_RESULTS: Final = 50 + +NUM_CANDIDATES_MULTIPLIER: Final = 10 +MIN_NUM_CANDIDATES: Final = 100 +MAX_NUM_CANDIDATES: Final = 10_000 + +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 MongoDB Vector Search " + "index in MongoDB directly, then register it here by index name." +) + + +class _MongoDBSearchParams(BaseModel): + """Typed view over the vector store's litellm_params; unrelated keys are ignored.""" + + model_config = ConfigDict(frozen=True, extra="ignore") + + litellm_embedding_model: str | None = None + litellm_embedding_config: Mapping[str, object] | None = None + mongodb_connection_string: str | None = None + mongodb_database: str | None = None + mongodb_collection: str | None = None + mongodb_text_field: str | None = None + mongodb_embedding_field: str | None = None + mongodb_num_candidates: int | None = None + + @property + def text_field(self) -> str: + return self.mongodb_text_field or DEFAULT_TEXT_FIELD_NAME + + @property + def embedding_field(self) -> str: + return self.mongodb_embedding_field or DEFAULT_EMBEDDING_FIELD_NAME + + def require_embedding_model(self) -> str: + if not self.litellm_embedding_model: + raise config_error( + "litellm_embedding_model is required in litellm_params for the MongoDB vector store. " + "It must be the same model that produced the vectors stored in " + f"'{self.mongodb_collection or ''}.{self.embedding_field}', or search results " + "will be meaningless. Example: litellm_embedding_model: openai/text-embedding-3-small" + ) + return self.litellm_embedding_model + + def require_connection_string(self) -> str: + 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 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"): + raise config_error( + "mongodb_connection_string must start with 'mongodb://' or 'mongodb+srv://', " + f"got '{self.mongodb_connection_string.split('://', 1)[0]}://'" + ) + return self.mongodb_connection_string + + def require_database(self) -> str: + if not self.mongodb_database: + raise config_error( + "mongodb_database is required in litellm_params for the MongoDB vector store. " + "Example: mongodb_database: sample_mflix" + ) + return self.mongodb_database + + def require_collection(self) -> str: + if not self.mongodb_collection: + raise config_error( + "mongodb_collection is required in litellm_params for the MongoDB vector store. " + "Example: mongodb_collection: embedded_movies" + ) + 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, + embedding_executor: VectorStoreEmbeddingExecutor | None = None, + sync_client_factory: Callable[[MongoClientKey], object] | None = None, + async_client_factory: Callable[[MongoClientKey], object] | None = None, + ) -> None: + super().__init__() + self.embedding_executor: Final[VectorStoreEmbeddingExecutor] = ( + embedding_executor if embedding_executor is not None else LiteLLMVectorStoreEmbeddingExecutor() + ) + self.sync_client_factory: Final[Callable[[MongoClientKey], object]] = ( + sync_client_factory if sync_client_factory is not None else get_sync_client + ) + self.async_client_factory: Final[Callable[[MongoClientKey], object]] = ( + 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: + """Without this a mistyped mongodb_collection reads as 'mongodb_collection is required', + naming 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) + if not text.strip(): + raise config_error("query must not be empty") + if len(text) > MAX_QUERY_CHARACTERS: + raise config_error(f"query must be at most {MAX_QUERY_CHARACTERS} characters, got {len(text)}") + return text + + @staticmethod + def _limit(vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams) -> int: + requested: Final = vector_store_search_optional_params.get("max_num_results") + if requested is None: + return DEFAULT_MAX_NUM_RESULTS + if not MIN_MAX_NUM_RESULTS <= requested <= MAX_MAX_NUM_RESULTS: + raise config_error( + f"max_num_results must be between {MIN_MAX_NUM_RESULTS} and {MAX_MAX_NUM_RESULTS}, got {requested}" + ) + return requested + + @staticmethod + def _num_candidates(limit: int, configured: int | None) -> int: + if configured is not None: + if not limit <= configured <= MAX_NUM_CANDIDATES: + raise config_error( + f"mongodb_num_candidates must be between max_num_results ({limit}) and " + f"{MAX_NUM_CANDIDATES}, got {configured}" + ) + return configured + return min(max(limit * NUM_CANDIDATES_MULTIPLIER, MIN_NUM_CANDIDATES), MAX_NUM_CANDIDATES) + + @staticmethod + def _timeout_ms(timeout: float | httpx.Timeout | None) -> tuple[int, int]: + """The connect and socket budgets pymongo is built with, in that order.""" + if isinstance(timeout, httpx.Timeout): + return ( + int((timeout.connect or DEFAULT_CONNECT_TIMEOUT_MS / 1000) * 1000), + int((timeout.read or DEFAULT_SOCKET_TIMEOUT_MS / 1000) * 1000), + ) + if timeout is None: + return DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_SOCKET_TIMEOUT_MS + return min(int(float(timeout) * 1000), DEFAULT_CONNECT_TIMEOUT_MS), int(float(timeout) * 1000) + + @classmethod + def _client_key(cls, params: _MongoDBSearchParams, timeout: float | httpx.Timeout | None) -> MongoClientKey: + connect_ms, socket_ms = cls._timeout_ms(timeout) + return MongoClientKey( + connection_string=params.require_connection_string(), + connect_timeout_ms=connect_ms, + socket_timeout_ms=socket_ms, + server_selection_timeout_ms=min(connect_ms, DEFAULT_SERVER_SELECTION_TIMEOUT_MS), + ) + + @classmethod + def _pipeline( + cls, + vector_store_id: str, + query_vector: Sequence[float], + params: _MongoDBSearchParams, + vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams, + ) -> Sequence[Mapping[str, object]]: + 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 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 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: + raise config_error( + "MongoDB vector store does not support the rewrite_query parameter. The query is " + "embedded exactly as sent; rewrite it before calling if you need that." + ) + limit: Final = cls._limit(vector_store_search_optional_params) + search: Final = MappingProxyType( + { + "index": vector_store_id, + "path": params.embedding_field, + "queryVector": tuple(query_vector), + "numCandidates": cls._num_candidates(limit, params.mongodb_num_candidates), + "limit": limit, + } + ) + projection: Final = MappingProxyType( + {params.text_field: 1, SCORE_FIELD_NAME: MappingProxyType({"$meta": "vectorSearchScore"})} + ) + return [ # mutable-ok: pymongo rejects any non-list pipeline in common.validate_list + MappingProxyType({"$vectorSearch": search}), + MappingProxyType({"$project": projection}), + ] + + @classmethod + def _field_value(cls, document: Mapping[str, object], dotted_path: str) -> str | None: + """None means absent, which is what separates a mistyped field from genuinely empty text.""" + head, _, rest = dotted_path.partition(".") + if head not in document: + return None + value: Final = document[head] + if not rest: + return None if value is None else str(value) + return cls._field_value(value, rest) if isinstance(value, Mapping) else None + + @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) or "", type="text") + ] + raw_score: Final = document.get(SCORE_FIELD_NAME) + return VectorStoreSearchResult( + score=float(raw_score) if isinstance(raw_score, (int, float)) else None, + content=content, + file_id=identifier, + filename=identifier, + ) + + @classmethod + def _raise_for_missing_text_field( + cls, documents: Sequence[Mapping[str, object]], text_field: str, database: str, collection: str + ) -> None: + """$vectorSearch matches documents carrying no text, so a mistyped mongodb_text_field + returns well-scored results with empty content instead of failing.""" + 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 + ) -> VectorStoreSearchResponse: + return VectorStoreSearchResponse( + object="vector_store.search_results.page", + search_query=query_text, + data=[ # mutable-ok: VectorStoreSearchResponse declares data as a list + cls._to_result(document, text_field) for document in documents + ], + ) + + @staticmethod + def _raise_for_unusable_index( + catalogue: Sequence[Mapping[str, object]], index_name: str, database: str, collection: str + ) -> None: + """mongod returns zero documents both for a query that matched nothing and for a missing + database, collection or index, so the catalogue decides which one happened.""" + if not catalogue: + raise missing_index_error(index_name, database, collection) + entry: Final = catalogue[0] + if not entry.get("queryable"): + raise index_not_ready_error(index_name, database, collection, str(entry.get("status") or "unknown")) + + @staticmethod + def _embedding_vector(embedding_response: EmbeddingResponse) -> Sequence[float]: + data: Final = embedding_response.data + if not data: + raise config_error( + "The embedding model returned no embedding for the search query, so there is nothing " + "to search MongoDB with. Check the embedding deployment named by litellm_embedding_model." + ) + return data[0]["embedding"] + + def execute_search_vector_store_request( + self, + vector_store_id: str, + query: str | Sequence[str], + vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams, + litellm_logging_obj: "LiteLLMLoggingObj", + litellm_params: Mapping[str, object], + embedding_executor: VectorStoreEmbeddingExecutor | None = None, + 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) + database: Final = params.require_database() + collection: Final = params.require_collection() + + embedding_response: Final = (embedding_executor or self.embedding_executor).embed( + params.require_embedding_model(), + query_text, + params.litellm_embedding_config or _EMPTY_EMBEDDING_CONFIG, + ) + pipeline: Final = self._pipeline( + vector_store_id, self._embedding_vector(embedding_response), params, vector_store_search_optional_params + ) + + try: + client: Final = self.sync_client_factory(key) + target: Final = client[database][collection] # pyright: ignore[reportIndexIssue] # factory is typed as returning object so injected doubles are accepted + documents: Final = tuple(target.aggregate(pipeline)) + except Exception as e: + raise translate_mongo_error(e, index_name=vector_store_id, database=database, collection=collection) from e + if not documents: + try: + catalogue: Final = tuple(target.list_search_indexes(vector_store_id)) + except Exception as e: + raise translate_mongo_error( + 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( + self, + vector_store_id: str, + query: str | Sequence[str], + vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams, + litellm_logging_obj: "LiteLLMLoggingObj", + litellm_params: Mapping[str, object], + embedding_executor: VectorStoreEmbeddingExecutor | None = None, + 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) + database: Final = params.require_database() + collection: Final = params.require_collection() + + embedding_response: Final = await (embedding_executor or self.embedding_executor).aembed( + params.require_embedding_model(), + query_text, + params.litellm_embedding_config or _EMPTY_EMBEDDING_CONFIG, + ) + pipeline: Final = self._pipeline( + vector_store_id, self._embedding_vector(embedding_response), params, vector_store_search_optional_params + ) + + try: + client: Final = self.async_client_factory(key) + target: Final = client[database][collection] # pyright: ignore[reportIndexIssue] # factory is typed as returning object so injected doubles are accepted + cursor: Final = await target.aggregate(pipeline) + documents: Final = [ # mutable-ok: an async comprehension cannot build a tuple directly + document async for document in cursor + ] + except Exception as e: + raise translate_mongo_error(e, index_name=vector_store_id, database=database, collection=collection) from e + if not documents: + try: + index_cursor: Final = await target.list_search_indexes(vector_store_id) + catalogue: Final = [ # mutable-ok: an async comprehension cannot build a tuple directly + entry async for entry in index_cursor + ] + except Exception as e: + raise translate_mongo_error( + 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( + self, + vector_store_create_optional_params: VectorStoreCreateOptionalRequestParams, + api_base: str, + ) -> NoReturn: + raise config_error(_SEARCH_ONLY_MESSAGE) + + def transform_create_vector_store_response(self, response: httpx.Response) -> NoReturn: + raise config_error(_SEARCH_ONLY_MESSAGE) diff --git a/litellm/proxy/vector_store_endpoints/management_endpoints.py b/litellm/proxy/vector_store_endpoints/management_endpoints.py index fe4732c6492..5eb3d43a739 100644 --- a/litellm/proxy/vector_store_endpoints/management_endpoints.py +++ b/litellm/proxy/vector_store_endpoints/management_endpoints.py @@ -56,7 +56,7 @@ def _row_to_vector_store(row: "_VectorStoreRow") -> LiteLLM_ManagedVectorStore: return LiteLLM_ManagedVectorStore(**row.model_dump()) -_LITELLM_PARAMS_MASKER: Final = SensitiveDataMasker() +_LITELLM_PARAMS_MASKER: Final = SensitiveDataMasker(extra_sensitive_patterns=frozenset(("connection",))) _REDACT_LITELLM_PARAMS_MAX_DEPTH: Final = 10 diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 44c312e2e77..6745e65f81e 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3888,6 +3888,7 @@ class LlmProviders(str, Enum): PG_VECTOR = "pg_vector" S3_VECTORS = "s3_vectors" VALKEY = "valkey" + MONGODB = "mongodb" HELICONE = "helicone" HYPERBOLIC = "hyperbolic" RECRAFT = "recraft" diff --git a/litellm/utils.py b/litellm/utils.py index 8b1b32ea328..9d20d32d147 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8989,6 +8989,12 @@ class ProviderConfigManager: ) return ValkeyVectorStoreConfig() + elif litellm.LlmProviders.MONGODB == provider: + from litellm.llms.mongodb.vector_stores.transformation import ( + MongoDBVectorStoreConfig, + ) + + return MongoDBVectorStoreConfig() return None @staticmethod diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index ebc220b3496..41ed8e1d975 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -2880,6 +2880,13 @@ "vector_stores_search": true } }, + "mongodb": { + "display_name": "MongoDB Atlas (`mongodb`)", + "url": "https://docs.litellm.ai/docs/providers/mongodb_vector_stores", + "endpoints": { + "vector_stores_search": true + } + }, "valkey": { "display_name": "Valkey (`valkey`)", "url": "https://docs.litellm.ai/docs/providers/valkey_vector_stores", diff --git a/pyproject.toml b/pyproject.toml index 5567fb5d6e2..c1fde4af3f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,9 @@ utils = [ ] caching = ["diskcache>=5.6.3,<6.0"] mcp = ["mcp>=1.28.1,<2.0"] +# Driver for the MongoDB Atlas vector store; Atlas Vector Search has no HTTP query API. +# The floor is 4.9 because that is the release AsyncMongoClient landed in. +mongodb = ["pymongo>=4.9,<5.0"] # SAML SSO for the admin UI. python3-saml pulls in xmlsec/lxml, whose wheels # bundle the native libxmlsec1/libxml2 libraries, so no system packages are # required. Kept out of the base `proxy` extra so it stays optional. diff --git a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py index fadc4ca49e9..c8b6772d965 100644 --- a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py +++ b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py @@ -314,6 +314,36 @@ def test_mask_credentials_in_payload_masks_only_sensitive_string_leaves(): assert masked.endswith(plaintext[-4:]) +def test_extra_sensitive_patterns_add_to_the_defaults(): + from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker + + masker = SensitiveDataMasker(extra_sensitive_patterns={"connection"}) + + assert masker.is_sensitive_key("mongodb_connection_string") is True + assert masker.is_sensitive_key("api_key") is True + assert masker.is_sensitive_key("aws_secret_access_key") is True + assert masker.is_sensitive_key("mongodb_database") is False + + +def test_extra_sensitive_patterns_do_not_leak_into_other_maskers(): + from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker + + SensitiveDataMasker(extra_sensitive_patterns={"connection"}) + + assert SensitiveDataMasker().is_sensitive_key("mongodb_connection_string") is False + + +def test_the_second_positional_argument_is_still_the_override_set(): + """SensitiveDataMasker is public SDK surface, so adding a keyword must not shift what an + existing positional call means. Putting extra_sensitive_patterns second would silently turn + an override set into an extra sensitive set and start masking the caller's pricing fields.""" + from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker + + masker = SensitiveDataMasker({"token"}, {"session"}) + + assert masker.is_sensitive_key("session_token") is False + assert masker.is_sensitive_key("auth_token") is True + def test_redact_credentials_in_payload_leaves_no_fragment_of_the_secret(): """A payload rendered straight to stdout cannot afford the partial reveal mask_credentials_in_payload leaves, so every credential-named value is replaced 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 new file mode 100644 index 00000000000..f5d31c0da54 --- /dev/null +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -0,0 +1,1537 @@ +import asyncio +import gc +import sys +import threading +import weakref +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +import litellm +from litellm.exceptions import BadRequestError, ServiceUnavailableError, Timeout +from litellm.llms.mongodb.common_utils import ( + _MAX_CACHED_CLIENTS, + _async_clients, + _sync_clients, + MongoClientKey, + index_not_ready_error, + missing_index_error, + get_async_client, + get_sync_client, + reset_client_cache, + translate_mongo_error, +) +from litellm.llms.mongodb.vector_stores.transformation import ( + MongoDBVectorStoreConfig, + _MongoDBSearchParams, +) +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager + +CONNECTION_STRING = "mongodb+srv://user:pw@cluster.example.mongodb.net" +INDEX = "movies_vector_index" + +BASE_PARAMS = { + "litellm_embedding_model": "openai/text-embedding-ada-002", + "mongodb_connection_string": CONNECTION_STRING, + "mongodb_database": "sample_mflix", + "mongodb_collection": "embedded_movies", +} + + +READY_INDEX = [{"name": INDEX, "status": "READY", "queryable": True}] + + +class RecordingClient: + """Stands in for pymongo's client class so the cache tests inject a fake rather than + patching the importer, and so they can assert what the client was actually built with.""" + + def __init__(self, connection_string, **kwargs): + self.connection_string = connection_string + self.kwargs = kwargs + + +class FakeCollection: + def __init__(self, documents, error=None, search_indexes=None): + self.documents = documents + self.error = error + self.search_indexes = READY_INDEX if search_indexes is None else search_indexes + self.pipeline = None + self.listed_indexes = [] + + def aggregate(self, pipeline): + self.pipeline = pipeline + if self.error is not None: + raise self.error + return iter(self.documents) + + def list_search_indexes(self, name): + self.listed_indexes.append(name) + return iter(self.search_indexes) + + +class FakeAsyncCollection(FakeCollection): + async def aggregate(self, pipeline): + self.pipeline = pipeline + if self.error is not None: + raise self.error + + async def cursor(): + for document in self.documents: + yield document + + return cursor() + + async def list_search_indexes(self, name): + self.listed_indexes.append(name) + + async def cursor(): + for entry in self.search_indexes: + yield entry + + return cursor() + + +class FakeDatabase: + def __init__(self, collection): + self.collection = collection + self.requested_collection = None + + def __getitem__(self, name): + self.requested_collection = name + return self.collection + + +class FakeClient: + def __init__(self, collection): + self.database = FakeDatabase(collection) + self.requested_database = None + + def __getitem__(self, name): + self.requested_database = name + return self.database + + +class FakeEmbeddingExecutor: + def __init__(self, embedding): + self.embedding = embedding + self.captured = None + + def _respond(self, model, query, configuration): + self.captured = SimpleNamespace(model=model, query=query, configuration=configuration) + return SimpleNamespace(data=[{"embedding": self.embedding}] if self.embedding is not None else []) + + def embed(self, model, query, configuration): + return self._respond(model, query, configuration) + + async def aembed(self, model, query, configuration): + return self._respond(model, query, configuration) + + +def _config(documents=(), embedding=(0.1, 0.2, 0.3), error=None, search_indexes=None): + collection = FakeCollection(list(documents), error, search_indexes) + client = FakeClient(collection) + config = MongoDBVectorStoreConfig( + embedding_executor=FakeEmbeddingExecutor(list(embedding) if embedding is not None else None), + sync_client_factory=lambda key: client, + ) + return config, client, collection + + +def _async_config(documents=(), embedding=(0.1, 0.2, 0.3), error=None, search_indexes=None): + collection = FakeAsyncCollection(list(documents), error, search_indexes) + client = FakeClient(collection) + config = MongoDBVectorStoreConfig( + embedding_executor=FakeEmbeddingExecutor(list(embedding) if embedding is not None else None), + async_client_factory=lambda key: client, + ) + return config, client, collection + + +def _search(config, query="a lone astronaut", optional_params=None, litellm_params=None, timeout=None): + return config.execute_search_vector_store_request( + vector_store_id=INDEX, + query=query, + vector_store_search_optional_params=optional_params or {}, + litellm_logging_obj=MagicMock(), + litellm_params={**BASE_PARAMS, **(litellm_params or {})}, + timeout=timeout, + ) + + +async def _asearch(config, query="a lone astronaut", optional_params=None, litellm_params=None): + return await config.aexecute_search_vector_store_request( + vector_store_id=INDEX, + query=query, + vector_store_search_optional_params=optional_params or {}, + litellm_logging_obj=MagicMock(), + litellm_params={**BASE_PARAMS, **(litellm_params or {})}, + ) + + +def _stage(collection, name): + return next(stage[name] for stage in collection.pipeline if name in stage) + + +def test_search_builds_vector_search_stage_against_the_named_index(): + config, client, collection = _config() + + _search(config, optional_params={"max_num_results": 5}) + + assert client.requested_database == "sample_mflix" + assert client.database.requested_collection == "embedded_movies" + assert _stage(collection, "$vectorSearch") == { + "index": INDEX, + "path": "embedding", + "queryVector": (0.1, 0.2, 0.3), + "numCandidates": 100, + "limit": 5, + } + + +def test_the_pipeline_reaches_pymongo_as_a_list(): + """pymongo's common.validate_list rejects any other sequence with + 'pipeline must be a list, not ', so the outer container is part of the contract.""" + config, _, collection = _config() + + _search(config) + + assert isinstance(collection.pipeline, list) + + +def test_search_projects_the_text_field_and_the_similarity_score(): + config, _, collection = _config() + + _search(config) + + assert _stage(collection, "$project") == {"text": 1, "score": {"$meta": "vectorSearchScore"}} + + +def test_search_defaults_to_ten_results(): + config, _, collection = _config() + + _search(config) + + assert _stage(collection, "$vectorSearch")["limit"] == 10 + + +def test_search_honors_custom_field_names(): + config, _, collection = _config() + + _search( + config, + litellm_params={"mongodb_embedding_field": "plot_embedding", "mongodb_text_field": "plot"}, + ) + + assert _stage(collection, "$vectorSearch")["path"] == "plot_embedding" + assert _stage(collection, "$project") == {"plot": 1, "score": {"$meta": "vectorSearchScore"}} + + +def test_num_candidates_scales_with_the_requested_limit(): + config, _, collection = _config() + + _search(config, optional_params={"max_num_results": 40}) + + assert _stage(collection, "$vectorSearch")["numCandidates"] == 400 + + +def test_num_candidates_can_be_overridden(): + config, _, collection = _config() + + _search(config, optional_params={"max_num_results": 5}, litellm_params={"mongodb_num_candidates": 250}) + + assert _stage(collection, "$vectorSearch")["numCandidates"] == 250 + + +@pytest.mark.parametrize("configured", [4, 10_001]) +def test_num_candidates_below_the_limit_or_above_the_ceiling_is_rejected(configured): + config, _, _ = _config() + + with pytest.raises(BadRequestError, match="mongodb_num_candidates"): + _search(config, optional_params={"max_num_results": 5}, litellm_params={"mongodb_num_candidates": configured}) + + +def test_list_query_is_joined_into_one_embedding_input(): + config, _, _ = _config() + + _search(config, query=["deep", "space", "rescue"]) + + assert config.embedding_executor.captured.query == "deep space rescue" + + +def test_embedding_config_is_expanded_into_the_embedding_call(): + config, _, _ = _config() + + _search(config, litellm_params={"litellm_embedding_config": {"api_base": "https://example.test", "timeout": 7}}) + + captured = config.embedding_executor.captured + assert captured.configuration == {"api_base": "https://example.test", "timeout": 7} + assert captured.model == "openai/text-embedding-ada-002" + + +def test_response_maps_documents_to_openai_shaped_results(): + documents = [ + {"_id": "abc123", "text": "an astronaut adrift", "score": 0.94}, + {"_id": "def456", "text": "a robot dog", "score": 0.81}, + ] + config, _, _ = _config(documents=documents) + + response = _search(config) + + assert response["object"] == "vector_store.search_results.page" + assert response["search_query"] == "a lone astronaut" + assert [result["score"] for result in response["data"]] == [0.94, 0.81] + assert [result["content"][0]["text"] for result in response["data"]] == ["an astronaut adrift", "a robot dog"] + assert [result["file_id"] for result in response["data"]] == ["abc123", "def456"] + assert [result["filename"] for result in response["data"]] == ["abc123", "def456"] + assert response["data"][0]["content"][0]["type"] == "text" + + +def test_response_reads_a_dotted_text_field_path(): + config, _, _ = _config(documents=[{"_id": 1, "metadata": {"body": "nested text"}, "score": 0.5}]) + + response = _search(config, litellm_params={"mongodb_text_field": "metadata.body"}) + + assert response["data"][0]["content"][0]["text"] == "nested text" + + +def test_a_dotted_path_resolves_three_levels_deep(): + config, _, _ = _config(documents=[{"_id": 1, "a": {"b": {"c": "deep text"}}, "score": 0.5}]) + + response = _search(config, litellm_params={"mongodb_text_field": "a.b.c"}) + + assert response["data"][0]["content"][0]["text"] == "deep text" + + +def test_a_dotted_path_that_runs_through_a_scalar_counts_as_absent(): + """Walking 'plot.nope' when plot is a string must report the misconfiguration, not + stringify the scalar and hand the model text from the wrong field.""" + config, _, _ = _config(documents=[{"_id": 1, "plot": "a plain string", "score": 0.5}]) + + with pytest.raises(BadRequestError, match=r"has a 'plot\.nope' field"): + _search(config, litellm_params={"mongodb_text_field": "plot.nope"}) + + +def test_a_non_string_text_field_is_stringified(): + config, _, _ = _config(documents=[{"_id": 1, "year": 1979, "score": 0.5}]) + + response = _search(config, litellm_params={"mongodb_text_field": "year"}) + + assert response["data"][0]["content"][0]["text"] == "1979" + + +def test_a_null_text_field_counts_as_absent(): + config, _, _ = _config(documents=[{"_id": 1, "text": None, "score": 0.5}]) + + with pytest.raises(BadRequestError, match="has a 'text' field"): + _search(config) + + +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(): + config, _, _ = _config(documents=[{"_id": 1, "text": "no score"}]) + + response = _search(config) + + assert response["data"][0]["score"] is None + + +def test_response_stringifies_a_non_string_document_id(): + config, _, _ = _config(documents=[{"_id": 12345, "text": "numeric id", "score": 0.5}]) + + response = _search(config) + + assert response["data"][0]["file_id"] == "12345" + + +def test_search_requires_an_embedding_model(): + config, _, _ = _config() + + with pytest.raises(BadRequestError, match="litellm_embedding_model is required"): + config.execute_search_vector_store_request( + vector_store_id=INDEX, + query="q", + vector_store_search_optional_params={}, + litellm_logging_obj=MagicMock(), + litellm_params={k: v for k, v in BASE_PARAMS.items() if k != "litellm_embedding_model"}, + ) + + +def test_missing_embedding_model_message_names_the_field_being_searched(): + config, _, _ = _config() + + with pytest.raises(BadRequestError, match=r"embedded_movies\.embedding"): + config.execute_search_vector_store_request( + vector_store_id=INDEX, + query="q", + vector_store_search_optional_params={}, + litellm_logging_obj=MagicMock(), + litellm_params={k: v for k, v in BASE_PARAMS.items() if k != "litellm_embedding_model"}, + ) + + +def test_search_requires_a_connection_string(): + config, _, _ = _config() + + with pytest.raises(BadRequestError, match="mongodb_connection_string is required"): + _search(config, litellm_params={"mongodb_connection_string": None}) + + +@pytest.mark.parametrize("connection_string", ["postgres://host/db", "https://cluster.mongodb.net", "redis://host"]) +def test_search_rejects_a_non_mongodb_connection_scheme(connection_string): + config, _, _ = _config() + + with pytest.raises(BadRequestError, match="must start with 'mongodb://' or 'mongodb\\+srv://'"): + _search(config, litellm_params={"mongodb_connection_string": connection_string}) + + +def test_search_accepts_the_plain_mongodb_scheme(): + config, _, collection = _config() + + _search(config, litellm_params={"mongodb_connection_string": "mongodb://localhost:27017"}) + + assert collection.pipeline is not None + + +def test_search_requires_a_database(): + config, _, _ = _config() + + with pytest.raises(BadRequestError, match="mongodb_database is required"): + _search(config, litellm_params={"mongodb_database": None}) + + +def test_search_requires_a_collection(): + config, _, _ = _config() + + with pytest.raises(BadRequestError, match="mongodb_collection is required"): + _search(config, litellm_params={"mongodb_collection": None}) + + +def test_search_rejects_filters_rather_than_silently_ignoring_them(): + config, _, _ = _config() + + with pytest.raises(BadRequestError, match="does not support the filters parameter"): + _search(config, optional_params={"filters": {"genre": "sci-fi"}}) + + +@pytest.mark.asyncio +async def test_async_search_rejects_filters_rather_than_silently_ignoring_them(): + config, _, _ = _async_config() + + with pytest.raises(BadRequestError, match="does not support the filters parameter"): + await _asearch(config, optional_params={"filters": {"genre": "sci-fi"}}) + + +def test_search_rejects_ranking_options_rather_than_silently_ignoring_them(): + """A score_threshold that is quietly dropped is worse than an error: the caller asked for + results above 0.9, gets results scoring 0.5, and nothing says the threshold never ran.""" + config, _, _ = _config() + + with pytest.raises(BadRequestError, match="does not support the ranking_options parameter"): + _search(config, optional_params={"ranking_options": {"score_threshold": 0.9}}) + + +def test_search_rejects_rewrite_query_rather_than_silently_ignoring_it(): + config, _, _ = _config() + + with pytest.raises(BadRequestError, match="does not support the rewrite_query parameter"): + _search(config, optional_params={"rewrite_query": True}) + + +@pytest.mark.asyncio +async def test_async_search_rejects_ranking_options_rather_than_silently_ignoring_them(): + config, _, _ = _async_config() + + with pytest.raises(BadRequestError, match="does not support the ranking_options parameter"): + await _asearch(config, optional_params={"ranking_options": {"score_threshold": 0.9}}) + + +@pytest.mark.parametrize("query", ["", " ", "\n\t", []]) +def test_search_rejects_an_empty_query(query): + config, _, _ = _config() + + with pytest.raises(BadRequestError, match="query must not be empty"): + _search(config, query=query) + + +def test_search_rejects_an_oversized_query(): + config, _, _ = _config() + + with pytest.raises(BadRequestError, match="at most 32000 characters"): + _search(config, query="x" * 32_001) + + +def test_search_accepts_a_query_at_the_size_ceiling(): + config, _, collection = _config() + + _search(config, query="x" * 32_000) + + assert collection.pipeline is not None + + +@pytest.mark.parametrize("max_num_results", [0, -1, 51, 1000]) +def test_search_rejects_out_of_range_max_num_results(max_num_results): + config, _, _ = _config() + + with pytest.raises(BadRequestError, match="max_num_results must be between 1 and 50"): + _search(config, optional_params={"max_num_results": max_num_results}) + + +@pytest.mark.parametrize("max_num_results", [1, 50]) +def test_search_allows_max_num_results_at_the_bounds(max_num_results): + config, _, collection = _config() + + _search(config, optional_params={"max_num_results": max_num_results}) + + assert _stage(collection, "$vectorSearch")["limit"] == max_num_results + + +def test_search_treats_an_explicit_null_max_num_results_as_the_default(): + config, _, collection = _config() + + _search(config, optional_params={"max_num_results": None}) + + assert _stage(collection, "$vectorSearch")["limit"] == 10 + + +def test_search_fails_when_the_embedding_model_returns_nothing(): + config, _, _ = _config(embedding=None) + + with pytest.raises(BadRequestError, match="returned no embedding"): + _search(config) + + +def test_validation_runs_before_any_connection_is_opened(): + opened = [] + config = MongoDBVectorStoreConfig( + embedding_executor=FakeEmbeddingExecutor([0.1]), + sync_client_factory=lambda key: opened.append(key) or FakeClient(FakeCollection([])), + ) + + with pytest.raises(BadRequestError, match="query must not be empty"): + _search(config, query="") + + assert opened == [] + + +def test_create_vector_store_is_not_supported_and_says_why(): + """litellm.exception_type only passes its own exception types through untouched, so a + NotImplementedError here reaches the caller as APIConnectionError, which the proxy serves + as a 500 with a traceback. Refusing an unsupported operation is a client error.""" + config = MongoDBVectorStoreConfig() + + with pytest.raises(BadRequestError, match="search-only"): + config.transform_create_vector_store_request({}, "https://example.test") + + with pytest.raises(BadRequestError, match="search-only"): + config.transform_create_vector_store_response(httpx.Response(200)) + + +def test_the_create_refusal_survives_the_public_sdk_error_wrapper(): + import litellm + + with pytest.raises(BadRequestError) as raised: + litellm.vector_stores.create(custom_llm_provider="mongodb", name="anything") + + assert "search-only" in str(raised.value) + + +def test_provider_config_manager_returns_the_mongodb_config(): + config = ProviderConfigManager.get_provider_vector_stores_config(LlmProviders.MONGODB) + + assert isinstance(config, MongoDBVectorStoreConfig) + + +@pytest.mark.asyncio +async def test_async_search_builds_the_same_pipeline_and_maps_the_response(): + documents = [{"_id": "abc123", "text": "an astronaut adrift", "score": 0.94}] + config, client, collection = _async_config(documents=documents) + + response = await _asearch(config, optional_params={"max_num_results": 3}) + + assert client.requested_database == "sample_mflix" + assert client.database.requested_collection == "embedded_movies" + assert _stage(collection, "$vectorSearch")["limit"] == 3 + assert _stage(collection, "$vectorSearch")["queryVector"] == (0.1, 0.2, 0.3) + assert response["data"][0]["content"][0]["text"] == "an astronaut adrift" + assert response["data"][0]["score"] == 0.94 + + +@pytest.mark.asyncio +async def test_async_search_requires_an_embedding_model(): + config, _, _ = _async_config() + + with pytest.raises(BadRequestError, match="litellm_embedding_model is required"): + await config.aexecute_search_vector_store_request( + vector_store_id=INDEX, + query="q", + vector_store_search_optional_params={}, + litellm_logging_obj=MagicMock(), + litellm_params={k: v for k, v in BASE_PARAMS.items() if k != "litellm_embedding_model"}, + ) + + +class TestClientCache: + def setup_method(self): + reset_client_cache() + + def teardown_method(self): + reset_client_cache() + + def _key(self, connection_string=CONNECTION_STRING, socket_timeout_ms=30_000): + return MongoClientKey( + connection_string=connection_string, + connect_timeout_ms=10_000, + socket_timeout_ms=socket_timeout_ms, + server_selection_timeout_ms=10_000, + ) + + def test_the_same_connection_reuses_one_client(self): + first = get_sync_client(self._key(), RecordingClient) + second = get_sync_client(self._key(), RecordingClient) + + assert first is second + assert first.connection_string == CONNECTION_STRING + assert first.kwargs["socketTimeoutMS"] == 30_000 + assert first.kwargs["connectTimeoutMS"] == 10_000 + assert first.kwargs["appname"] == "litellm" + + def test_a_different_connection_gets_its_own_client(self): + first = get_sync_client(self._key(), RecordingClient) + second = get_sync_client(self._key(connection_string="mongodb://other.example.test"), RecordingClient) + + assert first is not second + assert second.connection_string == "mongodb://other.example.test" + + def test_a_different_timeout_gets_its_own_client(self): + first = get_sync_client(self._key(), RecordingClient) + second = get_sync_client(self._key(socket_timeout_ms=5_000), RecordingClient) + + assert first is not second + assert second.kwargs["socketTimeoutMS"] == 5_000 + + @pytest.mark.asyncio + async def test_async_clients_are_cached_per_event_loop(self): + first = get_async_client(self._key(), RecordingClient) + second = get_async_client(self._key(), RecordingClient) + + assert first is second + assert first.connection_string == CONNECTION_STRING + + + def _fill_cache(self): + for slot in range(_MAX_CACHED_CLIENTS): + get_sync_client(self._key(f"mongodb://cold-{slot}:27017"), RecordingClient) + + def test_a_store_added_after_the_cache_filled_is_still_cached(self): + """Rebuilding a client costs an SRV lookup, a TLS handshake and topology discovery, so a + store that misses the cache on every single search pays that on every search.""" + self._fill_cache() + latecomer = self._key("mongodb://latecomer:27017") + + first = get_sync_client(latecomer, RecordingClient) + + assert get_sync_client(latecomer, RecordingClient) is first + + def test_the_cache_evicts_the_least_recently_used_client(self): + self._fill_cache() + oldest = self._key("mongodb://cold-0:27017") + newest = self._key(f"mongodb://cold-{_MAX_CACHED_CLIENTS - 1}:27017") + kept = get_sync_client(newest, RecordingClient) + + get_sync_client(self._key("mongodb://latecomer:27017"), RecordingClient) + + assert get_sync_client(newest, RecordingClient) is kept + assert oldest not in _sync_clients + + def test_concurrent_searches_never_trip_over_an_eviction(self): + """Async searches run the sync client through executor threads, so a key can be evicted + between the lookup and the reordering that follows it.""" + errors = [] + churn = _MAX_CACHED_CLIENTS + 2 + + def hammer(offset): + try: + for step in range(3_000): + get_sync_client(self._key(f"mongodb://h-{(step + offset) % churn}:27017"), RecordingClient) + except Exception as e: + errors.append(repr(e)) + + previous = sys.getswitchinterval() + sys.setswitchinterval(1e-9) + try: + threads = [threading.Thread(target=hammer, args=(offset,)) for offset in range(16)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + finally: + sys.setswitchinterval(previous) + + assert errors == [] + + def test_the_cache_never_grows_past_its_cap(self): + for slot in range(_MAX_CACHED_CLIENTS * 3): + get_sync_client(self._key(f"mongodb://host-{slot}:27017"), RecordingClient) + + assert len(_sync_clients) == _MAX_CACHED_CLIENTS + + def test_a_new_loop_never_inherits_a_closed_loop_client(self): + """CPython recycles id() so aggressively that a fresh event loop almost always lands on + the id of one already collected: measured at 37 of 40 rounds. Keying the cache on the id + alone therefore hands the new loop an AsyncMongoClient bound to a closed loop, and every + operation on it raises "Event loop is closed".""" + + class LoopAgnosticClient: + """Holds no reference to the loop, unlike pymongo's, whose own reference happens to + keep ids from being recycled and hides the bug until the cache fills.""" + + def __init__(self, *args, **kwargs): + self.built_on = None + + key = self._key() + clients_handed_out = [] + + async def fetch(): + return get_async_client(key, LoopAgnosticClient) + + for _ in range(20): + loop = asyncio.new_event_loop() + client = loop.run_until_complete(fetch()) + clients_handed_out.append((client, client.built_on, loop.is_closed())) + client.built_on = weakref.ref(loop) + loop.close() + del loop + gc.collect() + + stale = [ + handed_out + for client, built_on, _ in clients_handed_out + if built_on is not None and (built_on() is None or built_on().is_closed()) + for handed_out in (client,) + ] + assert stale == [], f"{len(stale)} of 20 loops were handed a client built on a closed loop" + + def test_the_cache_releases_clients_built_on_closed_loops(self): + """pymongo's AsyncMongoClient keeps a reference to the loop it was built on, so an entry + for a closed loop holds that client, and its sockets, for the life of the process. A + script calling asyncio.run per search fills the cache to its cap that way: measured live + against Atlas at 32 pinned clients and 212 open descriptors after 40 loops.""" + + class LoopHoldingClient: + def __init__(self, *args, **kwargs): + self.loop = asyncio.get_running_loop() + + key = self._key() + + async def fetch(): + return get_async_client(key, LoopHoldingClient) + + for _ in range(_MAX_CACHED_CLIENTS + 8): + loop = asyncio.new_event_loop() + loop.run_until_complete(fetch()) + loop.close() + + assert len(_async_clients) == 1, f"{len(_async_clients)} closed-loop clients are still cached" + + +class TestClientKeyDerivation: + def test_no_timeout_uses_the_bounded_defaults(self): + key = MongoDBVectorStoreConfig._client_key(_MongoDBSearchParams.model_validate(BASE_PARAMS), None) + + assert key.connect_timeout_ms == 10_000 + assert key.socket_timeout_ms == 30_000 + assert key.server_selection_timeout_ms == 10_000 + + def test_a_numeric_timeout_bounds_the_connect_phase(self): + key = MongoDBVectorStoreConfig._client_key(_MongoDBSearchParams.model_validate(BASE_PARAMS), 3.0) + + assert key.socket_timeout_ms == 3_000 + assert key.connect_timeout_ms == 3_000 + + def test_a_short_timeout_also_shortens_server_selection(self): + """Server selection runs before the connect attempt, so leaving it at the 10s default + would let a caller asking for a 3s budget block for 10s before anything is tried.""" + key = MongoDBVectorStoreConfig._client_key(_MongoDBSearchParams.model_validate(BASE_PARAMS), 3.0) + + assert key.server_selection_timeout_ms == 3_000 + + def test_a_generous_timeout_does_not_raise_server_selection_above_the_default(self): + key = MongoDBVectorStoreConfig._client_key(_MongoDBSearchParams.model_validate(BASE_PARAMS), 120.0) + + assert key.socket_timeout_ms == 120_000 + assert key.server_selection_timeout_ms == 10_000 + + def test_an_httpx_timeout_maps_connect_and_read_separately(self): + key = MongoDBVectorStoreConfig._client_key( + _MongoDBSearchParams.model_validate(BASE_PARAMS), httpx.Timeout(connect=2.0, read=45.0, write=5.0, pool=5.0) + ) + + assert key.connect_timeout_ms == 2_000 + assert key.socket_timeout_ms == 45_000 + + +class TestErrorTranslation: + def _translate(self, error): + return translate_mongo_error(error, index_name=INDEX, database="sample_mflix", collection="embedded_movies") + + def test_server_selection_timeout_points_at_the_atlas_access_list(self): + from pymongo.errors import ServerSelectionTimeoutError + + translated = self._translate(ServerSelectionTimeoutError("no servers")) + + assert "IP access list" in str(translated) + assert "paused cluster" in str(translated) + + def test_authentication_failure_points_at_the_connection_string_credentials(self): + from pymongo.errors import OperationFailure + + translated = self._translate(OperationFailure("auth failed", code=18)) + + assert "rejected the credentials" in str(translated) + + def test_a_dropped_connection_stays_retryable(self): + """A replica set failover reaches the driver as AutoReconnect. litellm only retries 408, + 409, 429 and 5xx, so classifying it as a client error would turn one failover into a + permanently failed search.""" + from pymongo.errors import AutoReconnect + + translated = self._translate(AutoReconnect("connection closed")) + + assert litellm._should_retry(translated.status_code) + assert "dropped or refused" in str(translated) + + def test_a_dropped_connection_still_names_the_misconfigurations_behind_it(self): + """Atlas answers a URI with no credentials by closing the connection rather than failing + auth, so the retryable message still has to name that.""" + from pymongo.errors import AutoReconnect + + translated = self._translate(AutoReconnect("connection closed")) + + assert "no username and password" in str(translated) + assert "mongod is listening" in str(translated) + + def test_the_retryable_classification_survives_the_public_sdk_error_wrapper(self): + """litellm.exception_type only passes its own exception types through; anything else becomes + an APIConnectionError and a 500, which would drop the retryable classification.""" + from pymongo.errors import AutoReconnect + + translated = self._translate(AutoReconnect("connection closed")) + + wrapped = litellm.exception_type( + model=None, + original_exception=translated, + custom_llm_provider="mongodb", + completion_kwargs={}, + extra_kwargs={}, + ) + + assert isinstance(wrapped, ServiceUnavailableError) + assert litellm._should_retry(wrapped.status_code) + + def test_a_pool_wait_queue_timeout_stays_retryable(self): + from pymongo.errors import WaitQueueTimeoutError + + translated = self._translate(WaitQueueTimeoutError("timed out waiting for a connection")) + + assert litellm._should_retry(translated.status_code) + + def test_server_selection_timeout_still_wins_over_the_connection_branch(self): + from pymongo.errors import ServerSelectionTimeoutError + + translated = self._translate(ServerSelectionTimeoutError("no servers")) + + assert isinstance(translated, Timeout) + assert "dropped or refused" not in str(translated) + + def test_network_timeout_still_wins_over_the_connection_branch(self): + from pymongo.errors import NetworkTimeout + + translated = self._translate(NetworkTimeout("socket timed out")) + + assert isinstance(translated, Timeout) + assert "dropped or refused" 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, 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) + assert "percent-encoded" in str(translated) + + def test_unauthorized_points_at_the_database_user_permissions(self): + from pymongo.errors import OperationFailure + + translated = self._translate(OperationFailure("not authorized", code=13)) + + assert "sample_mflix.embedded_movies" in str(translated) + + def test_code_13_alone_is_enough_without_a_recognisable_message(self): + """The other unauthorized case carries "not authorized", which the message markers also + match, so it cannot tell whether the code is still being checked at all.""" + from pymongo.errors import OperationFailure + + translated = self._translate(OperationFailure("user lacks privileges on this namespace", code=13)) + + assert "rejected the credentials" in str(translated) + assert "sample_mflix.embedded_movies" in str(translated) + + def test_a_missing_index_names_the_index_and_the_collection(self): + from pymongo.errors import OperationFailure + + translated = self._translate(OperationFailure("Index not found for name movies_vector_index", code=27)) + + assert INDEX in str(translated) + assert "READY" in str(translated) + + def test_a_dimension_mismatch_points_at_the_embedding_model(self): + from pymongo.errors import OperationFailure + + translated = self._translate(OperationFailure("queryVector has 1536 dimensions, index expects 2048")) + + assert "litellm_embedding_model must be the same model" in str(translated) + + def test_an_unrecognised_operation_failure_still_names_the_target(self): + from pymongo.errors import OperationFailure + + translated = self._translate(OperationFailure("something else entirely")) + + assert "sample_mflix.embedded_movies" in str(translated) + assert INDEX in str(translated) + + def test_a_configuration_error_points_at_the_connection_string(self): + from pymongo.errors import ConfigurationError + + translated = self._translate(ConfigurationError("bad uri")) + + assert "not a usable MongoDB connection string" in str(translated) + + def test_a_non_driver_error_is_returned_unchanged(self): + original = RuntimeError("unrelated") + + assert self._translate(original) is original + + def test_search_surfaces_a_translated_driver_error(self): + from pymongo.errors import ServerSelectionTimeoutError + + config, _, _ = _config(error=ServerSelectionTimeoutError("no servers")) + + with pytest.raises(Timeout, match="IP access list"): + _search(config) + + @pytest.mark.asyncio + async def test_async_search_surfaces_a_translated_driver_error(self): + from pymongo.errors import OperationFailure + + config, _, _ = _async_config(error=OperationFailure("auth failed", code=18)) + + with pytest.raises(BadRequestError, match="rejected the credentials"): + await _asearch(config) + + +class TestMissingDriver: + def test_the_sync_import_names_the_extra_to_install(self): + from litellm.llms.mongodb.common_utils import import_sync_mongo_client + + with patch.dict(sys.modules, {"pymongo": None}): + with pytest.raises(BadRequestError, match=r"pip install litellm\[mongodb\]"): + import_sync_mongo_client() + + def test_the_async_import_names_the_extra_to_install(self): + from litellm.llms.mongodb.common_utils import import_async_mongo_client + + with patch.dict(sys.modules, {"pymongo": None}): + with pytest.raises(BadRequestError, match=r"pip install litellm\[mongodb\]"): + import_async_mongo_client() + + def test_error_translation_degrades_gracefully_without_the_driver(self): + original = RuntimeError("boom") + + with patch.dict(sys.modules, {"pymongo.errors": None}): + assert translate_mongo_error(original, INDEX, "db", "col") is original + + +class TestEmptyResultsAreDisambiguated: + """$vectorSearch returns zero documents for a missing database, collection or index just as it + does for a query that matched nothing, so an empty result set is checked against the index + catalogue before it is reported as 'no matches'.""" + + 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 MongoDB Vector Search index"): + _search(config) + + assert collection.listed_indexes == [INDEX] + + def test_the_missing_index_error_explains_why_mongodb_reported_no_results(self): + config, _, _ = _config(documents=[], search_indexes=[]) + + with pytest.raises(BadRequestError, match="returns no results rather than an error"): + _search(config) + + def test_an_index_still_building_becomes_an_error_naming_its_status(self): + config, _, _ = _config( + documents=[], search_indexes=[{"name": INDEX, "status": "PENDING", "queryable": False}] + ) + + with pytest.raises(BadRequestError, match="not queryable yet; its status is PENDING"): + _search(config) + + def test_a_genuine_no_match_against_a_ready_index_returns_an_empty_page(self): + config, _, collection = _config(documents=[]) + + response = _search(config) + + assert response["data"] == [] + assert response["object"] == "vector_store.search_results.page" + assert collection.listed_indexes == [INDEX] + + def test_the_catalogue_is_not_consulted_when_the_search_returned_hits(self): + config, _, collection = _config(documents=[{"_id": 1, "text": "hit", "score": 0.9}]) + + _search(config) + + assert collection.listed_indexes == [] + + @pytest.mark.asyncio + 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 MongoDB Vector Search index"): + await _asearch(config) + + assert collection.listed_indexes == [INDEX] + + @pytest.mark.asyncio + async def test_async_index_still_building_becomes_an_error_naming_its_status(self): + config, _, _ = _async_config( + documents=[], search_indexes=[{"name": INDEX, "status": "PENDING", "queryable": False}] + ) + + with pytest.raises(BadRequestError, match="not queryable yet; its status is PENDING"): + await _asearch(config) + + @pytest.mark.asyncio + async def test_async_genuine_no_match_returns_an_empty_page(self): + config, _, _ = _async_config(documents=[]) + + response = await _asearch(config) + + assert response["data"] == [] + + @pytest.mark.asyncio + async def test_async_catalogue_is_not_consulted_when_the_search_returned_hits(self): + config, _, collection = _async_config(documents=[{"_id": 1, "text": "hit", "score": 0.9}]) + + await _asearch(config) + + assert collection.listed_indexes == [] + + def test_a_failure_while_checking_the_catalogue_is_translated_too(self): + from pymongo.errors import OperationFailure + + class ExplodingCollection(FakeCollection): + def list_search_indexes(self, name): + raise OperationFailure("not authorized", code=13) + + collection = ExplodingCollection([], None, []) + config = MongoDBVectorStoreConfig( + embedding_executor=FakeEmbeddingExecutor([0.1]), + sync_client_factory=lambda key: FakeClient(collection), + ) + + with pytest.raises(BadRequestError, match="lacks read access"): + _search(config) + + +class TestAtlasPlanExecutorErrors: + """Atlas reports a wrong vector path and a dimension mismatch through the same error code, so + each one has to be told apart by its message or both come back as a generic index failure.""" + + def _translate(self, message): + from pymongo.errors import OperationFailure + + return translate_mongo_error( + OperationFailure(message, code=8), + index_name=INDEX, + database="sample_mflix", + collection="embedded_movies", + ) + + def test_a_wrong_vector_path_points_at_the_embedding_field_setting(self): + translated = self._translate( + "PlanExecutor error during aggregation :: caused by :: nope is not indexed as vector" + ) + + assert "mongodb_embedding_field names a field" in str(translated) + + def test_a_dimension_mismatch_is_not_reported_as_a_wrong_path(self): + translated = self._translate( + "PlanExecutor error during aggregation :: caused by :: vector field is indexed with " + "1536 dimensions but queried with 3072" + ) + + assert "does not match the vector dimensions" in str(translated) + assert "mongodb_embedding_field" not in str(translated) + + +class TestErrorsCarryTheRightHttpStatus: + """litellm.exception_type passes a litellm exception through untouched but wraps anything + else into APIConnectionError, which the proxy serves as a 500 with a Python traceback in the + body. A misconfigured connection string is the caller's to fix, so it has to arrive as a 400. + """ + + @pytest.mark.parametrize( + "invoke", + [ + pytest.param(lambda: _search(_config()[0], query=" "), id="empty-query"), + pytest.param( + lambda: _search(_config()[0], optional_params={"max_num_results": 999}), + id="max-num-results-out-of-range", + ), + pytest.param( + lambda: _search(_config()[0], optional_params={"filters": {"genre": "Action"}}), + id="unsupported-filters", + ), + pytest.param( + lambda: _search(_config()[0], litellm_params={"mongodb_connection_string": "postgres://host/db"}), + id="wrong-uri-scheme", + ), + pytest.param( + lambda: _search(_config()[0], litellm_params={"mongodb_database": None}), id="missing-database" + ), + pytest.param( + lambda: _search(_config()[0], litellm_params={"litellm_embedding_model": None}), + id="missing-embedding-model", + ), + ], + ) + def test_configuration_failures_are_400(self, invoke): + with pytest.raises(BadRequestError) as excinfo: + invoke() + assert excinfo.value.status_code == 400 + assert excinfo.value.llm_provider == "mongodb" + + def test_missing_index_is_400(self): + error = missing_index_error("idx", "db", "coll") + assert error.status_code == 400 + assert error.llm_provider == "mongodb" + + def test_index_still_building_is_400(self): + error = index_not_ready_error("idx", "db", "coll", "PENDING") + assert error.status_code == 400 + + def test_unreachable_deployment_is_a_timeout_not_a_bad_request(self): + from pymongo.errors import ServerSelectionTimeoutError + + translated = translate_mongo_error( + ServerSelectionTimeoutError("no servers"), index_name="idx", database="db", collection="coll" + ) + assert isinstance(translated, Timeout) + assert translated.status_code == 408 + + def test_query_execution_timeout_is_a_timeout(self): + from pymongo.errors import ExecutionTimeout + + translated = translate_mongo_error( + ExecutionTimeout("too slow"), index_name="idx", database="db", collection="coll" + ) + assert isinstance(translated, Timeout) + assert translated.status_code == 408 + + def test_unrecognised_errors_are_not_relabelled_as_bad_requests(self): + original = RuntimeError("something else entirely") + assert ( + translate_mongo_error(original, index_name="idx", database="db", collection="coll") + is original + ) + + +def test_atlas_rejected_credentials_are_named_even_though_the_code_is_8000(): + """Atlas answers a wrong password with code 8000 "AtlasError", not the 18 that a + self-hosted deployment returns, so a code-only check reports it as a generic + rejected search and never tells the caller to look at their connection string.""" + from pymongo.errors import OperationFailure + + error = OperationFailure( + "bad auth : authentication failed", + code=8000, + details={"ok": 0, "errmsg": "bad auth : authentication failed", "code": 8000, "codeName": "AtlasError"}, + ) + translated = translate_mongo_error(error, index_name="idx", database="sample_mflix", collection="embedded_movies") + + assert isinstance(translated, BadRequestError) + assert "mongodb_connection_string" in str(translated) + assert "sample_mflix.embedded_movies" in str(translated) + + +def test_a_rejected_search_that_is_not_an_auth_failure_keeps_the_generic_message(): + from pymongo.errors import OperationFailure + + error = OperationFailure("PlanExecutor error", code=8, details={"errmsg": "PlanExecutor error"}) + 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"}) + + +class TestClientConstructionFailures: + """Building the client parses the URI and, for mongodb+srv://, performs a DNS SRV lookup, so it + fails on exactly the inputs a user is most likely to get wrong. Constructing it outside the + translation boundary let those escape as raw pymongo errors, which litellm.exception_type then + wrapped into a 500 with a traceback in the body.""" + + def _config_that_fails_to_connect(self, error): + def factory(_key): + raise error + + return MongoDBVectorStoreConfig( + embedding_executor=FakeEmbeddingExecutor([0.1, 0.2, 0.3]), sync_client_factory=factory + ) + + def _async_config_that_fails_to_connect(self, error): + def factory(_key): + raise error + + return MongoDBVectorStoreConfig( + embedding_executor=FakeEmbeddingExecutor([0.1, 0.2, 0.3]), async_client_factory=factory + ) + + def test_a_malformed_uri_is_a_bad_request_not_a_500(self): + from pymongo.errors import InvalidURI + + config = self._config_that_fails_to_connect(InvalidURI("Invalid URI scheme")) + + with pytest.raises(BadRequestError, match="not a usable MongoDB connection string"): + _search(config) + + def test_an_unresolvable_cluster_name_says_so(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, match="does not exist in DNS"): + _search(config) + + def test_a_dns_lookup_that_ran_out_of_time_is_a_timeout(self): + from pymongo.errors import ConfigurationError + + config = self._config_that_fails_to_connect( + ConfigurationError("The resolution lifetime expired after 0.291 seconds") + ) + + with pytest.raises(Timeout, match="did not finish in time"): + _search(config) + + @pytest.mark.asyncio + async def test_the_async_path_translates_them_too(self): + from pymongo.errors import InvalidURI + + config = self._async_config_that_fails_to_connect(InvalidURI("Invalid URI scheme")) + + 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_executor=FakeEmbeddingExecutor([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(ServiceUnavailableError) 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) + + +class TestUnreadableTlsFilesAreDiagnosed: + """A private CA is how self-managed deployments present TLS, so tlsCAFile and + tlsCertificateKeyFile are on-prem options in practice. pymongo opens those files itself and + lets OSError out, which is not a PyMongoError, so before this they reached the caller as a 500 + with a traceback. The errors here come from pymongo's real TLS setup.""" + + @staticmethod + def _real_tls_error(uri): + from pymongo import MongoClient + + try: + MongoClient(uri, serverSelectionTimeoutMS=1500).admin.command("ping") + except Exception as e: + return e + raise AssertionError(f"expected {uri!r} to fail") + + def _translated(self, uri): + return translate_mongo_error(self._real_tls_error(uri), index_name=INDEX, database="db", collection="c") + + @pytest.mark.parametrize( + "path", + ["/nonexistent-directory-for-tests/ca.pem", "/tmp"], + ) + def test_an_unreadable_ca_file_is_a_400_naming_the_path(self, path): + translated = self._translated(f"mongodb://localhost:27717/?tls=true&tlsCAFile={path}") + + assert isinstance(translated, BadRequestError) + assert path in str(translated) + assert "tlsCAFile" in str(translated) + + def test_an_unreadable_client_certificate_is_a_400_naming_the_path(self): + path = "/nonexistent-directory-for-tests/client.pem" + translated = self._translated(f"mongodb://localhost:27717/?tls=true&tlsCertificateKeyFile={path}") + + assert isinstance(translated, BadRequestError) + assert path in str(translated) + + def test_an_oserror_carrying_no_filename_is_left_for_the_other_branches(self): + translated = translate_mongo_error(OSError("socket hung up"), index_name=INDEX, database="db", collection="c") + + assert not isinstance(translated, BadRequestError) + + +class TestTheCallerSuppliedEmbeddingExecutorIsUsed: + """litellm.vector_stores.search always hands a direct provider an embedding_executor, so the + provider has to accept it and route the query through it rather than its own default.""" + + def test_the_supplied_executor_produces_the_query_vector(self): + config, _, collection = _config(embedding=(0.9, 0.9, 0.9), search_indexes=READY_INDEX) + caller = FakeEmbeddingExecutor([0.4, 0.5, 0.6]) + + config.execute_search_vector_store_request( + vector_store_id=INDEX, + query="a lone astronaut", + vector_store_search_optional_params={}, + litellm_logging_obj=MagicMock(), + litellm_params=BASE_PARAMS, + embedding_executor=caller, + ) + + assert caller.captured.query == "a lone astronaut" + assert _stage(collection, "$vectorSearch")["queryVector"] == (0.4, 0.5, 0.6) + + @pytest.mark.asyncio + async def test_the_supplied_executor_produces_the_query_vector_on_the_async_path(self): + config, _, collection = _async_config(embedding=(0.9, 0.9, 0.9), search_indexes=READY_INDEX) + caller = FakeEmbeddingExecutor([0.4, 0.5, 0.6]) + + await config.aexecute_search_vector_store_request( + vector_store_id=INDEX, + query="a lone astronaut", + vector_store_search_optional_params={}, + litellm_logging_obj=MagicMock(), + litellm_params=BASE_PARAMS, + embedding_executor=caller, + ) + + assert caller.captured.query == "a lone astronaut" + assert _stage(collection, "$vectorSearch")["queryVector"] == (0.4, 0.5, 0.6) diff --git a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py index 1abbbe91e97..dc445ec007c 100644 --- a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py +++ b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py @@ -1,3 +1,4 @@ +import json from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch @@ -2463,6 +2464,41 @@ class TestRedactSensitiveLitellmParams: for k, v in params.items(): assert out[k] == v, f"{k} should be preserved verbatim" + def test_redacts_wire_protocol_connection_strings(self): + """ + A MongoDB vector store's whole credential is its connection string: + ``mongodb+srv://:@`` embeds the database + password, and none of the default api_key/secret/token patterns match + the key name, so an unextended masker returns it verbatim to every + caller of /vector_store/list and /vector_store/info. + """ + from litellm.constants import REDACTED_BY_LITELM_STRING + from litellm.proxy.vector_store_endpoints.management_endpoints import ( + _redact_sensitive_litellm_params, + ) + + password = "hunter2-not-for-callers" + params = { + "mongodb_connection_string": f"mongodb+srv://dbuser:{password}@cluster0.mongodb.net", + "mongodb_database": "sample_mflix", + "mongodb_collection": "embedded_movies", + "mongodb_embedding_field": "plot_embedding", + "mongodb_text_field": "plot", + "litellm_embedding_model": "openai/text-embedding-ada-002", + } + out = _redact_sensitive_litellm_params(params) + + assert out["mongodb_connection_string"] == REDACTED_BY_LITELM_STRING + assert password not in json.dumps(out) + for k in ( + "mongodb_database", + "mongodb_collection", + "mongodb_embedding_field", + "mongodb_text_field", + "litellm_embedding_model", + ): + assert out[k] == params[k], f"{k} is not a credential and must survive redaction" + def test_handles_none_and_empty(self): from litellm.proxy.vector_store_endpoints.management_endpoints import ( _redact_sensitive_litellm_params, diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 7d7066addcf..d7475173b90 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1208,11 +1208,6 @@ "count": 1 } }, - "src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx": { - "no-nested-ternary": { - "count": 2 - } - }, "src/app/(dashboard)/vector-stores/_components/index.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/public/assets/logos/mongodb.svg b/ui/litellm-dashboard/public/assets/logos/mongodb.svg new file mode 100644 index 00000000000..fb0d3cbdfab --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/mongodb.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.test.tsx index 71e2a7224ae..84a9314ecce 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.test.tsx @@ -69,6 +69,15 @@ describe("VectorStoreForm", () => { }); }); +const MONGODB_URI = "mongodb+srv://user:pass@cluster0.mongodb.net"; + +const MONGODB_REQUIRED_FORM_VALUES = { + mongodb_connection_string: MONGODB_URI, + mongodb_database: "sample_mflix", + mongodb_collection: "embedded_movies", + embedding_model: "text-embedding-ada-002", +}; + describe("buildVectorStoreLitellmParams", () => { it("renames embedding_model to litellm_embedding_model for valkey", () => { const valkeyFormValues = { @@ -110,6 +119,49 @@ describe("buildVectorStoreLitellmParams", () => { }); }); + it("renames embedding_model to litellm_embedding_model for mongodb", () => { + const formValues = { + ...MONGODB_REQUIRED_FORM_VALUES, + mongodb_embedding_field: "plot_embedding", + mongodb_text_field: "plot", + mongodb_num_candidates: "200", + }; + const expected = { + mongodb_connection_string: MONGODB_URI, + mongodb_database: "sample_mflix", + mongodb_collection: "embedded_movies", + mongodb_embedding_field: "plot_embedding", + mongodb_text_field: "plot", + mongodb_num_candidates: "200", + litellm_embedding_model: "text-embedding-ada-002", + }; + + expect(buildVectorStoreLitellmParams("mongodb", formValues)).toEqual(expected); + }); + + it("sends only mongodb fields when an earlier provider left values in the form", () => { + const formValues = { + ...MONGODB_REQUIRED_FORM_VALUES, + valkey_host: "left-over-from-valkey.example.com", + valkey_port: "6379", + aws_region_name: "us-west-2", + }; + + const params = buildVectorStoreLitellmParams("mongodb", formValues); + + expect(params).not.toHaveProperty("valkey_host"); + expect(params).not.toHaveProperty("valkey_port"); + expect(params).not.toHaveProperty("aws_region_name"); + expect(params.mongodb_connection_string).toBe(MONGODB_URI); + }); + + it("omits a blank mongodb_num_candidates so litellm picks its own candidate count", () => { + const params = buildVectorStoreLitellmParams("mongodb", MONGODB_REQUIRED_FORM_VALUES); + + expect(params.mongodb_num_candidates).toBeUndefined(); + expect(JSON.parse(JSON.stringify(params))).not.toHaveProperty("mongodb_num_candidates"); + }); + it("keeps embedding_model as-is for providers outside the rename set", () => { const params = buildVectorStoreLitellmParams("s3_vectors", { vector_bucket_name: "my-vector-bucket", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx index 9d78b727768..61da25874a5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx @@ -34,7 +34,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { useZodForm } from "@/lib/forms/useZodForm"; -const EMBEDDING_MODEL_RENAME_PROVIDERS = new Set(["milvus", "valkey"]); +const EMBEDDING_MODEL_RENAME_PROVIDERS = new Set(["milvus", "valkey", "mongodb"]); export const buildVectorStoreLitellmParams = ( provider: string, @@ -70,6 +70,12 @@ const PROVIDER_FIELD_NAMES = [ "vector_bucket_name", "index_name", "aws_region_name", + "mongodb_connection_string", + "mongodb_database", + "mongodb_collection", + "mongodb_embedding_field", + "mongodb_text_field", + "mongodb_num_candidates", "valkey_host", "valkey_port", "valkey_password", @@ -101,6 +107,12 @@ const vectorStoreShape = { vector_bucket_name: optionalText, index_name: optionalText, aws_region_name: optionalText, + mongodb_connection_string: optionalText, + mongodb_database: optionalText, + mongodb_collection: optionalText, + mongodb_embedding_field: optionalText, + mongodb_text_field: optionalText, + mongodb_num_candidates: optionalText, valkey_host: optionalText, valkey_port: optionalText, valkey_password: optionalText, @@ -126,10 +138,23 @@ const vectorStoreSchema = z.object(vectorStoreShape).superRefine((values, ctx) = type VectorStoreFormValues = z.output; +const VECTOR_STORE_ID_PLACEHOLDERS: Record = { + vertex_rag_engine: '6917529027641081856 (corpus ID from Vertex AI / "RAG Engine" console)', + "vertex_ai/search_api": 'my-datastore_1234567890 (data store ID from Vertex AI / "Agent Search" console)', + valkey: "my-search-index (FT index name in Valkey)", + mongodb: "my-vector-index (Atlas Vector Search index name)", +}; + +const VERTEX_SEARCH_API_WITH_ENGINE_PLACEHOLDER = "Any identifier you'll use to reference this in LiteLLM"; + +const DEFAULT_VECTOR_STORE_ID_PLACEHOLDER = "Enter vector store ID from your provider"; + const EMPTY_VALUES: VectorStoreFormValues = { custom_llm_provider: "bedrock", vector_store_id: "", vertex_location: "global", + mongodb_embedding_field: "embedding", + mongodb_text_field: "text", valkey_port: "6379", valkey_ssl: "false", valkey_text_field: "text", @@ -254,15 +279,9 @@ const VectorStoreForm: React.FC = ({ }; const vectorStoreIdPlaceholder = - selectedProvider === "vertex_rag_engine" - ? '6917529027641081856 (corpus ID from Vertex AI / "RAG Engine" console)' - : selectedProvider === "vertex_ai/search_api" - ? vertexEngineId - ? "Any identifier you'll use to reference this in LiteLLM" - : 'my-datastore_1234567890 (data store ID from Vertex AI / "Agent Search" console)' - : selectedProvider === "valkey" - ? "my-search-index (FT index name in Valkey)" - : "Enter vector store ID from your provider"; + selectedProvider === "vertex_ai/search_api" && vertexEngineId + ? VERTEX_SEARCH_API_WITH_ENGINE_PLACEHOLDER + : VECTOR_STORE_ID_PLACEHOLDERS[selectedProvider] ?? DEFAULT_VECTOR_STORE_ID_PLACEHOLDER; return ( !open && handleCancel()}> diff --git a/ui/litellm-dashboard/src/components/vector_store_providers.test.tsx b/ui/litellm-dashboard/src/components/vector_store_providers.test.tsx index eaf2a52853f..8e3a3aa3402 100644 --- a/ui/litellm-dashboard/src/components/vector_store_providers.test.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_providers.test.tsx @@ -28,6 +28,47 @@ describe("getVectorStoreProviderLogoAndName", () => { }); }); + it("registers mongodb in the provider, logo, and field maps", () => { + expect(getVectorStoreProviderLogoAndName("mongodb")).toEqual({ + logo: expect.stringContaining("mongodb"), + displayName: VectorStoreProviders.MongoDB, + }); + expect(vectorStoreProviderMap.MongoDB).toBe("mongodb"); + expect(getProviderSpecificFields("mongodb").map((field) => field.name)).toEqual([ + "mongodb_connection_string", + "mongodb_database", + "mongodb_collection", + "embedding_model", + "mongodb_embedding_field", + "mongodb_text_field", + "mongodb_num_candidates", + ]); + }); + + it("hides the mongodb connection string, which carries the database password", () => { + const connectionString = getProviderSpecificFields("mongodb").find( + (field) => field.name === "mongodb_connection_string", + ); + + expect(connectionString).toMatchObject({ type: "password", required: true }); + }); + + it("picks the mongodb embedding model from the proxy's models rather than a fixed list", () => { + const embeddingField = getProviderSpecificFields("mongodb").find((field) => field.name === "embedding_model"); + + expect(embeddingField).toMatchObject({ type: "select", required: true }); + expect(embeddingField).not.toHaveProperty("options"); + }); + + it("defaults the mongodb field names so a standard collection needs no extra input", () => { + const fields = getProviderSpecificFields("mongodb"); + const byName = (name: string) => fields.find((field) => field.name === name); + + expect(byName("mongodb_embedding_field")).toMatchObject({ required: false, initialValue: "embedding" }); + expect(byName("mongodb_text_field")).toMatchObject({ required: false, initialValue: "text" }); + expect(byName("mongodb_num_candidates")).toMatchObject({ required: false }); + }); + it("registers valkey in the provider, logo, and field maps", () => { expect(vectorStoreProviderMap.Valkey).toBe("valkey"); expect(vectorStoreProviderLogoMap[VectorStoreProviders.Valkey]).toContain("valkey"); diff --git a/ui/litellm-dashboard/src/components/vector_store_providers.tsx b/ui/litellm-dashboard/src/components/vector_store_providers.tsx index 35cd5c383f7..a75f10771a8 100644 --- a/ui/litellm-dashboard/src/components/vector_store_providers.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_providers.tsx @@ -1,5 +1,6 @@ import { getProviderLogoAndName, Providers, providerLogoMap } from "@/components/provider_info_helpers"; import milvusLogo from "../../public/assets/logos/milvus.svg"; +import mongodbLogo from "../../public/assets/logos/mongodb.svg"; import postgresqlLogo from "../../public/assets/logos/postgresql.svg"; import s3VectorLogo from "../../public/assets/logos/s3_vector.png"; import valkeyLogo from "../../public/assets/logos/valkey.svg"; @@ -13,6 +14,7 @@ export enum VectorStoreProviders { OpenAI = "OpenAI", Azure = "Azure OpenAI", Milvus = "Milvus", + MongoDB = "MongoDB Atlas", Valkey = "Valkey", } @@ -24,6 +26,7 @@ export const vectorStoreProviderMap: Record = { OpenAI: "openai", Azure: "azure", Milvus: "milvus", + MongoDB: "mongodb", S3Vectors: "s3_vectors", Valkey: "valkey", }; @@ -36,6 +39,7 @@ export const vectorStoreProviderLogoMap: Record = { [VectorStoreProviders.OpenAI]: providerLogoMap[Providers.OpenAI] ?? "", [VectorStoreProviders.Azure]: providerLogoMap[Providers.Azure] ?? "", [VectorStoreProviders.Milvus]: milvusLogo.src, + [VectorStoreProviders.MongoDB]: mongodbLogo.src, [VectorStoreProviders.S3Vectors]: s3VectorLogo.src, [VectorStoreProviders.Valkey]: valkeyLogo.src, }; @@ -169,6 +173,71 @@ export const vectorStoreProviderFields: Record type: "select", }, ], + mongodb: [ + { + name: "mongodb_connection_string", + label: "Connection String", + tooltip: + "The full MongoDB connection string for your Atlas cluster, including the database user and password. Copy it from Atlas under Connect, Drivers (e.g. mongodb+srv://user:password@cluster.mongodb.net)", + placeholder: "mongodb+srv://user:password@cluster.mongodb.net", + required: true, + type: "password", + }, + { + name: "mongodb_database", + label: "Database", + tooltip: "The Atlas database holding the collection you want to search", + placeholder: "sample_mflix", + required: true, + type: "text", + }, + { + name: "mongodb_collection", + label: "Collection", + tooltip: "The collection your Atlas Vector Search index was built on", + placeholder: "embedded_movies", + required: true, + type: "text", + }, + { + name: "embedding_model", + label: "Embedding Model", + tooltip: + "The embedding model on this proxy that created the vectors already stored in your collection. LiteLLM embeds every search query with it, so it must be the same model. A different model of the same size will not error, it will just return wrong results. Add it under Models first if it is not listed", + placeholder: "text-embedding-3-small", + required: true, + type: "select", + }, + { + name: "mongodb_embedding_field", + label: "Vector Field Name", + tooltip: + "The field in each document that holds its embedding. It must match the path your Atlas Vector Search index was created on (default: embedding)", + placeholder: "embedding", + required: false, + type: "text", + initialValue: "embedding", + }, + { + name: "mongodb_text_field", + label: "Text Field", + tooltip: + "The field in each document that holds its readable text. LiteLLM returns this text in search results, and it accepts a dotted path such as metadata.body (default: text)", + placeholder: "text", + required: false, + type: "text", + initialValue: "text", + }, + { + name: "mongodb_num_candidates", + label: "Candidates Considered", + tooltip: + "How many nearest neighbours Atlas examines before returning the top results. Higher is more accurate and slower. Leave blank to let LiteLLM scale it with the requested result count", + placeholder: "100", + required: false, + type: "text", + }, + ], valkey: [ { name: "valkey_host", diff --git a/uv.lock b/uv.lock index 99d694848f5..9e6343ff375 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-08-31T17:52:45.782441Z" +exclude-newer = "2026-09-01T21:00:02.682921Z" exclude-newer-span = "P3D" [manifest] @@ -4415,6 +4415,9 @@ mcp = [ mlflow = [ { name = "mlflow" }, ] +mongodb = [ + { name = "pymongo" }, +] proxy = [ { name = "apscheduler" }, { name = "azure-identity" }, @@ -4646,6 +4649,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.10.0,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.14.1,<3.0" }, { name = "pyjwt", marker = "extra == 'proxy'", specifier = ">=2.13.0,<3.0" }, + { name = "pymongo", marker = "extra == 'mongodb'", specifier = ">=4.9,<5.0" }, { name = "pynacl", marker = "extra == 'proxy'", specifier = ">=1.6.2,<2.0" }, { name = "pypdf", marker = "extra == 'proxy-runtime'", specifier = ">=6.16.1,<7.0" }, { name = "pyroscope-io", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.8.16,<1.0" }, @@ -4672,7 +4676,7 @@ requires-dist = [ { name = "uvloop", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.22.1,<1.0" }, { name = "websockets", marker = "extra == 'proxy'", specifier = ">=15.0.1,<16.0" }, ] -provides-extras = ["proxy", "cli", "extra-proxy", "utils", "caching", "mcp", "saml", "semantic-router", "mlflow", "grpc", "stt-nvidia-riva", "google", "bedrock-realtime", "proxy-runtime"] +provides-extras = ["proxy", "cli", "extra-proxy", "utils", "caching", "mcp", "mongodb", "saml", "semantic-router", "mlflow", "grpc", "stt-nvidia-riva", "google", "bedrock-realtime", "proxy-runtime"] [package.metadata.requires-dev] ci = [ @@ -7616,6 +7620,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" }, ] +[[package]] +name = "pymongo" +version = "4.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/64/50be6fbac9c79fe2e4c17401a467da2d8764d82833d83cec325afe5cab32/pymongo-4.17.0.tar.gz", hash = "sha256:70ffa08ba641468cc068cf46c06b34f01a8ce3489f6411309fcb5ceabe6b2fc0", size = 2523370, upload-time = "2026-04-20T16:39:53.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/77/28ebbf69772a4341d530831c7a006cdb06877ac23075cb53b0a227df4fe1/pymongo-4.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:47b021363cd923ace5edc7a1d63c0ff8a6d9d43859b8a1ba23645f5afae63221", size = 819234, upload-time = "2026-04-20T16:37:20.888Z" }, + { url = "https://files.pythonhosted.org/packages/88/cf/5a70cee503ff9a2fea20607607f14d189f4d975960ac0945ec306ee7b695/pymongo-4.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:422fa50d7d7f5c22ea0953554396c9ef95684a2d775f860bd75a7b510538dfca", size = 819969, upload-time = "2026-04-20T16:37:24.187Z" }, + { url = "https://files.pythonhosted.org/packages/23/d5/07b7e27e662c58d872efd104a0e8055eb6569aa1b6d4da436f3fdee7f897/pymongo-4.17.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:addd0498ebbdc6354227f6ed457ed9fce442d48a3bb30d5b5bad33e104996561", size = 1244510, upload-time = "2026-04-20T16:37:26.069Z" }, + { url = "https://files.pythonhosted.org/packages/fb/be/7cac5b1e89bd5a8e395067648241390321593a7c29243e36f91343c02a90/pymongo-4.17.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5c8e180cb2cabe37300e1e36c60aa4f2ff956cc579f0142135a5d2cba252243", size = 1263245, upload-time = "2026-04-20T16:37:28.003Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/40e8e99824c1fda18261411e65ce3b0cd3d9a6ed3c056cdd0a569adc870b/pymongo-4.17.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd835cdb37a1adec359dd072c24f8bb14809e2644fde86fab4ee2fc9719b9483", size = 1304113, upload-time = "2026-04-20T16:37:30.048Z" }, + { url = "https://files.pythonhosted.org/packages/3a/94/fb7e25441dd66f2069a9b172380849b0eaa5881c18b3db217bf64a6d393c/pymongo-4.17.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4979e7e8887862bbb44d203f00cc8263a3f27237876fa691b6beba23e40e6d8", size = 1297046, upload-time = "2026-04-20T16:37:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c9/7352e0c20fe772541556e4d283c05e07ec48f8b0d2737ad930ac4a1b6655/pymongo-4.17.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77aa4bc164b4de60d5db193b322f0f5b6ead716e831031bfdef8e8bd92205556", size = 1265708, upload-time = "2026-04-20T16:37:33.934Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e4/3df15494c2015ed297958517f0e4f6493e21b00990748068a973e66d45e0/pymongo-4.17.0-cp310-cp310-win32.whl", hash = "sha256:48bbc576677b50af043df870d84ded67cc3a9b4aa7553201beef4da5dc050a0a", size = 805533, upload-time = "2026-04-20T16:37:35.744Z" }, + { url = "https://files.pythonhosted.org/packages/22/fa/b4e71bb8cb82ad7d21bb4e8c476f2d573ba68b20368aac36ef06e4a196b4/pymongo-4.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46767f28dea610e02edf6c5d956ce615c3c7790ea396660b9b1efd5c5ead2e0", size = 815677, upload-time = "2026-04-20T16:37:37.808Z" }, + { url = "https://files.pythonhosted.org/packages/22/e2/0a4bba644f1cda3970ea1012149eeae3594ebfeed3f81fdaf32b61d90c95/pymongo-4.17.0-cp310-cp310-win_arm64.whl", hash = "sha256:757f2a4c0c2c46cab87df0333681ce69e86c9d5b45bc5203ceba5410b3489e59", size = 807293, upload-time = "2026-04-20T16:37:39.707Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e2/336d86f221cf1b56b2ed9330d4a3b98f9f38f0b37829ae9a9184617d5419/pymongo-4.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4141e6c6a339789b2974efa00ecd9409101672d77a0e3ee2cc3839eedf8ec4df", size = 874668, upload-time = "2026-04-20T16:37:41.39Z" }, + { url = "https://files.pythonhosted.org/packages/34/8e/75d3c6c935d187ab59c61e9c15d9aab3f274b563eaf1706e8cae5f508dec/pymongo-4.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e68c76b84e0c132d9dbf9307f12ff8185702328187a87b9aca8c941303873433", size = 875294, upload-time = "2026-04-20T16:37:43.432Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ec/62e855744489dbcd54fd778aae4d80fa4c4819e8fb228ca0cf6f21a03997/pymongo-4.17.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba2195d4f386f839a52a23ea1cfd60ffaaba78a3d7841db51b7e433001139918", size = 1496233, upload-time = "2026-04-20T16:37:45.518Z" }, + { url = "https://files.pythonhosted.org/packages/82/e8/93e4e5e5ce8fdf8929dabeefe24aafa5ce046028eed0dfa8eeb936e72c49/pymongo-4.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8446ff4bfcb6ec2a2e50998c860986a1e992136f998b7f53e7a717fb8aa5a0b9", size = 1522927, upload-time = "2026-04-20T16:37:47.492Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/425dc1d21e0f17bdea0072fc463f662f7fa06d2852af52975c9eced3c07c/pymongo-4.17.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2a0d5ac205728c86e0a02192f1aa5f865b0d7d51f8df6101c01a69a7fc620d72", size = 1583468, upload-time = "2026-04-20T16:37:49.221Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9d/f08b07eeffda1a43c1759f0fa625e88ae12360996eb56d42aad832fa7dff/pymongo-4.17.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:485c8a8eaa4c739f00a331fc73757898ee7c092c214a79e63866ff76aaf282ff", size = 1572787, upload-time = "2026-04-20T16:37:51.061Z" }, + { url = "https://files.pythonhosted.org/packages/e9/c2/6855a07aafa7b894929af23675b6fb9634800ce43122b76a62f6eeb8da2a/pymongo-4.17.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b2dfcc795f5b9fedbe179a11fdf6051581479d196582a3fe819a92a00e9b9969", size = 1526184, upload-time = "2026-04-20T16:37:53.358Z" }, + { url = "https://files.pythonhosted.org/packages/4e/05/c952bac7db71c1942ea3559fcd308b49754cc5004b455935fb4000d1f37b/pymongo-4.17.0-cp311-cp311-win32.whl", hash = "sha256:c2292144505fb12156b981bd440f3dc994a883da06ac726c0c8692ccdbc1c510", size = 852621, upload-time = "2026-04-20T16:37:55.28Z" }, + { url = "https://files.pythonhosted.org/packages/11/c0/c04da9f4c0c6252404598f4e394b862a58a9e866822a70ae261c8a018fdf/pymongo-4.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:2e190827834fce70ecdf9d46796c6dbc0ce08ea87dc2ff5bc6f3f5579b605cb9", size = 867852, upload-time = "2026-04-20T16:37:57.233Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b2/c7b4870fbeef471e947d3e014676f5910d02e0197074d692ebcf24ec049a/pymongo-4.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:a8f9c40a09bb7d4b9fc8b1da65ecf6efa79bda5cb2756f39d9b6940fac1d19ae", size = 855019, upload-time = "2026-04-20T16:37:58.983Z" }, + { url = "https://files.pythonhosted.org/packages/98/90/60bcb508840135d5ee46b51b1a950f548338aa8145a8366dbe6639ae51ac/pymongo-4.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53ffa94b2340dbf6b055e09a0090618c60482c158ecfc9565642fc996bf0944", size = 930529, upload-time = "2026-04-20T16:38:00.936Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e9/313840f1e52c6dfac47f704428cbfbce59956ebe7633bffc92b03f74f0ad/pymongo-4.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6fe0de9d0f6791abce3471230b32b4817bf89d27b1182b6a550e1ec0fa72aa9a", size = 930665, upload-time = "2026-04-20T16:38:02.915Z" }, + { url = "https://files.pythonhosted.org/packages/78/35/9d3565ea45b1606f635c1e2cd2563c28d66caafdc50f7ad7d979fcd1b363/pymongo-4.17.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e537e95514dae1aaa718f481ec03151a0f0394bcd05f1322896d8fc1330cb729", size = 1762369, upload-time = "2026-04-20T16:38:05.375Z" }, + { url = "https://files.pythonhosted.org/packages/95/ee/149b0d4b1a11c38bff6f14c23d5814c9b0843fd6dc38ad40596bdb1a62d2/pymongo-4.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37a8385c29881b43eab31f584100fa0eaddedd5607adf010147ba1810118be90", size = 1798044, upload-time = "2026-04-20T16:38:07.195Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d4/4cee4a7b8d8f6f0550ef6cd2fea42455c5ed619a220cb6ba4fb40d6a5bc8/pymongo-4.17.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f3ee3d241ed77a4fc99ce3cff3b289c3ebce37f61fdd7349d3592c23b82c8784", size = 1878567, upload-time = "2026-04-20T16:38:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/45/ef/7fe366c84952619ee2f69973566c214775e083dd4df465751912153e4b72/pymongo-4.17.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9eb5d63a3c518cb0804ed678f5e2b875af032d89a7cf57a57360322cf6a4d222", size = 1864881, upload-time = "2026-04-20T16:38:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/2f/35/b577d82c6d1be7aee7ac7e249bc86f7847998345042e5f8360de238e177b/pymongo-4.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e97e03fa13327c87e3fdc5656acd01e71817f0c1dc3221cd8f30de136bf4ec3", size = 1800349, upload-time = "2026-04-20T16:38:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/b8/69/dafcf04f66e130ddd91aeb92e7a692480eda46dcd04ec1dbe82c06619e10/pymongo-4.17.0-cp312-cp312-win32.whl", hash = "sha256:6877214bff5f06f6884a9fc8d9016a4a7a5f51f537f5c51ac3a576f93e7dfb32", size = 900518, upload-time = "2026-04-20T16:38:15.541Z" }, + { url = "https://files.pythonhosted.org/packages/11/35/5c9262a459f988b4eb2605f70815240b77a0d4131136c4326d18f1822b89/pymongo-4.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9828485f72f63c7d802e0ec41f71906f633c2692621ab3af55ca990186b091b1", size = 920335, upload-time = "2026-04-20T16:38:17.665Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/e9c7265ee176faccf4e52c4797837e794d93569a1046f6b19a4acc36e5ad/pymongo-4.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:1195370a77baf003b59b10e91ecc4706297197f0dd9d29c840cc556dc08f7cee", size = 903289, upload-time = "2026-04-20T16:38:19.33Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6b/c1206879708b94e82fcd8b9653440ec271f79a3674d122192df383047f5a/pymongo-4.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:809ec74de3b9148ae43fa8df9faf53470f511c8d384f13b99d6f671f2a379f15", size = 985829, upload-time = "2026-04-20T16:38:21.031Z" }, + { url = "https://files.pythonhosted.org/packages/cb/cf/bb044ed85160e5c40f568c7c4f4e8ea16f40764ff5d302e5befbe8f6f814/pymongo-4.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a431b737816bf4cddd4fa0fcef04e424ad36b7692734a64150f872fb8f3208be", size = 985899, upload-time = "2026-04-20T16:38:23.409Z" }, + { url = "https://files.pythonhosted.org/packages/74/0a/f6dfd5ea3901e5d6888da8de8ba728971a1d447debab681cfc56f90d1208/pymongo-4.17.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4fab10f8403169ce92f3cea921609d9ee81107306caae06c08f592d4b8ad2b5", size = 2028569, upload-time = "2026-04-20T16:38:25.343Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c5/081f59a1c02ae8c0dc73ae58e563838c44eec81aeafa7d0b93a637841c9b/pymongo-4.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20323b0b1c1d33770ad1fc68d429c757734ce9ad3594421c3d6618f10572b1b9", size = 2072916, upload-time = "2026-04-20T16:38:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/31/42/6e41d434297ffe8b30d9c3717916591a4a7be9075a0dcc2fafdfaaaa62ed/pymongo-4.17.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5a5de048e6da5c18e27cc2437e8c15b3b0cdc8385c15b41178b0caa3322a09c2", size = 2173234, upload-time = "2026-04-20T16:38:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/3d/cf/1e4a7db352ef9485831c7268dfe8402f0117b32a9ad54b16e810699e3617/pymongo-4.17.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dff3de1294fbbc1db0ba6b511f77b8e540601d092538a31312e99c8a91a78b1e", size = 2156784, upload-time = "2026-04-20T16:38:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/12/10/6195be29962a61ebb5f4bd9e4c7519890b172f7968a0a0d880398c6ddb02/pymongo-4.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faf03e4c2aafd6de626dbd30ba246d369ae33f47f10629d1bbe40f72115027a6", size = 2074446, upload-time = "2026-04-20T16:38:34.004Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/33410b8819837ed370c738587306bdf060b59cef11823be212f4a07703c5/pymongo-4.17.0-cp313-cp313-win32.whl", hash = "sha256:c9786665926a09630c5d420c79762cfadbff35a9438bcbc4c81a9fb5ab9228b7", size = 948435, upload-time = "2026-04-20T16:38:35.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/77/c0ed522f798a286b99acaa7914ed8d9c80ab091f97f57c59ffed72906e5e/pymongo-4.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:5960519b4d7168f1ecdd3ea10c81b2aedeb9423651aca953cfbc8e76705d3b38", size = 972847, upload-time = "2026-04-20T16:38:37.888Z" }, + { url = "https://files.pythonhosted.org/packages/97/f0/c39480a2db385fde23861d0c8acda41cdaf1d43e46579db72c5c013a2e81/pymongo-4.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:0ff6bd2f735ab5356541e3e57d5b7dbfbc3f2ee1ccb10b6b0f82d58af69d1d8e", size = 951575, upload-time = "2026-04-20T16:38:40.544Z" }, + { url = "https://files.pythonhosted.org/packages/da/49/2b0250762a89737ed6f9cea238331baca061b89a8ddd10dd17fee52c3970/pymongo-4.17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff5aa3f1c7e3f08eb0e7a016c91ba468b1850ccfd63d9b1f12f56350f4974cef", size = 1040945, upload-time = "2026-04-20T16:38:42.783Z" }, + { url = "https://files.pythonhosted.org/packages/89/1c/7a9b5447a08be20e84b6e5b17330917e8d6d9507daa3cd099a9309f11ad7/pymongo-4.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e816db649ba5d7de0568cf3a9f287a9dc9aad21cf0ca667ab156a7ef47fca0b0", size = 1041187, upload-time = "2026-04-20T16:38:45.358Z" }, + { url = "https://files.pythonhosted.org/packages/78/a1/71704f61632dfc90407a5834fe5f6132854937c4a3648f6c05c351d85a45/pymongo-4.17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c4fded3a9f1d6a687e36ebd384ac6d00b9b00de1969aa74048e7051ec2a713", size = 2294806, upload-time = "2026-04-20T16:38:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b9/aff42be75108b96c2469b1d9329b912c15108f3e7ef32fdc86da8423c330/pymongo-4.17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2db66aa8dd253a0fc1fad3b0d23d5b3993f7ebde02fbbd7727128debf2853675", size = 2348231, upload-time = "2026-04-20T16:38:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/f2/30/44c115b8ba1479942c15fd9480eb29a7da0ba68acd56983423ba0deb4a94/pymongo-4.17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3987e96e7c7be4083d42e8ac2cc6c0d5b78db9973c90fce42ae800b616ca6b20", size = 2467614, upload-time = "2026-04-20T16:38:52.665Z" }, + { url = "https://files.pythonhosted.org/packages/d2/84/21ee95c8bf0ca7acae7ec7eb365d740bf8fc0156c194baf2c3bdfcb85ec0/pymongo-4.17.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cee36b3c0d0354f880fa7a7fdcdaf2bb5e542c2281e25c1bfadf8cfe21eba7d2", size = 2445970, upload-time = "2026-04-20T16:38:55.175Z" }, + { url = "https://files.pythonhosted.org/packages/06/89/081d7f1809d5ca09d1e47e49f2111b245f5694de3a7af32cd3a353a6f43f/pymongo-4.17.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:320b34457b20bbcc79997801f95d25ce00472915ca5241167242b42c4359e027", size = 2348605, upload-time = "2026-04-20T16:38:57.557Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c3/0d949f9d3f2a341c1f635c398c16615e96f89f51ff424ed81e914cf1a4de/pymongo-4.17.0-cp314-cp314-win32.whl", hash = "sha256:df4a644af9ae132d4bfdb2e9516ea51a615fd881caddfbfbd071cf1354844479", size = 1004119, upload-time = "2026-04-20T16:39:00.309Z" }, + { url = "https://files.pythonhosted.org/packages/f7/55/5c3a3db1048054c695c75c5964cc8bedc2247fdb5a75ef6fab4ec8bb013e/pymongo-4.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:c797f8a80957134f6dd9690367a0f8f5906d672119af2c6aa55f0c527b656bed", size = 1032314, upload-time = "2026-04-20T16:39:02.665Z" }, + { url = "https://files.pythonhosted.org/packages/e0/19/e235f39906134cb0ffd5574c5a59c355ef5380f0499644ab94994afbb109/pymongo-4.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:68fca71e05ee5da23a8d73cee8379dfb3d26e609a377cae731d742771ed96946", size = 1007627, upload-time = "2026-04-20T16:39:04.678Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e0/c4c1a86791415b14c684fa0908f9da96de91594a3fd1fa1b8dc689fbb800/pymongo-4.17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b4384700cffc3f1dd98e088bc0072dedf6d7d68a230bb4b972665cf69c071c1e", size = 1099151, upload-time = "2026-04-20T16:39:06.969Z" }, + { url = "https://files.pythonhosted.org/packages/81/4b/69c67f3e23fd9b23b9bedc7ebd23754881cc9d5c5d5b2a9811e96b07f475/pymongo-4.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:93641192644fa1ee0f34030e774fd31022a27ad11ba22cb1716142231524f8bd", size = 1099346, upload-time = "2026-04-20T16:39:08.996Z" }, + { url = "https://files.pythonhosted.org/packages/a2/19/a5208f62f9508a26d73acc69bd3821b8c8adae253679a3c26d2f9652f0d5/pymongo-4.17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:75bc3aa5b94fdb7138d357ec6ca61cd97e0c79f4f7f0bd3efe9639b15cc50942", size = 2619034, upload-time = "2026-04-20T16:39:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/77/27/426cba1ec5973082a56d4150798529bfdf4151c31391ed1fbbecb23ef2ac/pymongo-4.17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e8f8e23c6df7c6d6929f5e734980b227706e73ee847517c9ba5af90f7fc466", size = 2689939, upload-time = "2026-04-20T16:39:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2e/f70993d1255e33f6ee59a4ec4371cc65bff7a7e3fda7d55c3386f25287e8/pymongo-4.17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:15d3f3d732aecac1f8d481bde4029755615639bd3076f258a2147210aec8515a", size = 2824994, upload-time = "2026-04-20T16:39:16.057Z" }, + { url = "https://files.pythonhosted.org/packages/b3/eb/87b0e988ba889e1fcc3430c2cfc166b251872c813e92b43174298bee17ff/pymongo-4.17.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5f62862d0f87be481fa1fe8cb811994486773c94a2b61e509285e3f2890763", size = 2801745, upload-time = "2026-04-20T16:39:18.476Z" }, + { url = "https://files.pythonhosted.org/packages/67/4c/3f83412d086f682d4d468761d66ddc49cf161e786ea74073045eb4491c60/pymongo-4.17.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64837adbbd72073301af51bb0fc80e3d7707fe5527cea1033ba0320f0b2f881b", size = 2684636, upload-time = "2026-04-20T16:39:20.878Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/b75f6f4ab6c8beb50b0270a4f1e2530b5774f5e116563440e1677ca1820f/pymongo-4.17.0-cp314-cp314t-win32.whl", hash = "sha256:b93b22eedc62598cf5ee9d8c8007a8e9121c50fd88137012d8985500e9dc3151", size = 1056356, upload-time = "2026-04-20T16:39:22.996Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5e/648c8a238eef18a25ed8a169ea6542d4a860bbec3e95b3d9badac2935c71/pymongo-4.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3689ea34f6b647c7d1e7bdc60fcfb214b2789ed1359a7fb96569c69f50e5f18f", size = 1090964, upload-time = "2026-04-20T16:39:24.989Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cb/d9780b66939c4fc1f024bcc7be23a2abcfe06a9745ca8fa76dc73395482e/pymongo-4.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9543d8f84c2e5608565c08ac679774811e6730770d8a645439b073422a4276fb", size = 1058526, upload-time = "2026-04-20T16:39:27.924Z" }, +] + [[package]] name = "pynacl" version = "1.6.2"