diff --git a/docs/edge/ar/concepts/knowledge.mdx b/docs/edge/ar/concepts/knowledge.mdx index 807e0801e9..0973a45278 100644 --- a/docs/edge/ar/concepts/knowledge.mdx +++ b/docs/edge/ar/concepts/knowledge.mdx @@ -27,11 +27,12 @@ mode: "wide" ### إعداد عميل المتجه (RAG) -يوفر CrewAI تجريدًا لعميل RAG محايد بالنسبة للمزود لمتاجر المتجهات. المزود الافتراضي هو ChromaDB، ويتم دعم Qdrant أيضًا. يمكنك التبديل بين المزودين باستخدام أدوات الإعداد. +يوفر CrewAI تجريدًا لعميل RAG محايد بالنسبة للمزود لمتاجر المتجهات. المزود الافتراضي هو ChromaDB، ويتم دعم Qdrant وMilvus أيضًا. يمكنك التبديل بين المزودين باستخدام أدوات الإعداد. المدعوم حاليًا: - ChromaDB (افتراضي) - Qdrant +- Milvus ```python Code from crewai.rag.config.utils import set_rag_config, get_rag_client, clear_rag_config @@ -46,12 +47,21 @@ from crewai.rag.qdrant.config import QdrantConfig set_rag_config(QdrantConfig()) qdrant_client = get_rag_client() +# Milvus (defaults to Milvus Lite at ./milvus.db) +from crewai.rag.milvus.config import MilvusConfig +set_rag_config(MilvusConfig()) +milvus_client = get_rag_client() + +# Milvus server or Zilliz Cloud +# set_rag_config(MilvusConfig(options={"uri": "http://localhost:19530"})) +# set_rag_config(MilvusConfig(options={"uri": "https://your-endpoint", "token": "your-token"})) + # Example operations (same API for any provider) -client = qdrant_client # or chromadb_client +client = milvus_client # or chromadb_client / qdrant_client client.create_collection(collection_name="docs") client.add_documents( collection_name="docs", - documents=[{"id": "1", "content": "CrewAI enables collaborative AI agents."}], + documents=[{"doc_id": "1", "content": "CrewAI enables collaborative AI agents."}], ) results = client.search(collection_name="docs", query="collaborative agents", limit=3) diff --git a/docs/edge/en/concepts/knowledge.mdx b/docs/edge/en/concepts/knowledge.mdx index 937cca1fda..00282f8cb7 100644 --- a/docs/edge/en/concepts/knowledge.mdx +++ b/docs/edge/en/concepts/knowledge.mdx @@ -27,11 +27,12 @@ Also, use relative paths from the `knowledge` directory when creating the source ### Vector store (RAG) client configuration -CrewAI exposes a provider-neutral RAG client abstraction for vector stores. The default provider is ChromaDB, and Qdrant is supported as well. You can switch providers using configuration utilities. +CrewAI exposes a provider-neutral RAG client abstraction for vector stores. The default provider is ChromaDB, and Qdrant and Milvus are supported as well. You can switch providers using configuration utilities. Supported today: - ChromaDB (default) - Qdrant +- Milvus ```python Code from crewai.rag.config.utils import set_rag_config, get_rag_client, clear_rag_config @@ -46,12 +47,21 @@ from crewai.rag.qdrant.config import QdrantConfig set_rag_config(QdrantConfig()) qdrant_client = get_rag_client() +# Milvus (defaults to Milvus Lite at ./milvus.db) +from crewai.rag.milvus.config import MilvusConfig +set_rag_config(MilvusConfig()) +milvus_client = get_rag_client() + +# Milvus server or Zilliz Cloud +# set_rag_config(MilvusConfig(options={"uri": "http://localhost:19530"})) +# set_rag_config(MilvusConfig(options={"uri": "https://your-endpoint", "token": "your-token"})) + # Example operations (same API for any provider) -client = qdrant_client # or chromadb_client +client = milvus_client # or chromadb_client / qdrant_client client.create_collection(collection_name="docs") client.add_documents( collection_name="docs", - documents=[{"id": "1", "content": "CrewAI enables collaborative AI agents."}], + documents=[{"doc_id": "1", "content": "CrewAI enables collaborative AI agents."}], ) results = client.search(collection_name="docs", query="collaborative agents", limit=3) @@ -1093,5 +1103,3 @@ This is useful when you've updated your knowledge sources and want to ensure tha - Use environment variables for API keys and sensitive configuration - - diff --git a/lib/crewai/pyproject.toml b/lib/crewai/pyproject.toml index 2e484df9d8..d586b3f659 100644 --- a/lib/crewai/pyproject.toml +++ b/lib/crewai/pyproject.toml @@ -77,6 +77,9 @@ docling = [ qdrant = [ "qdrant-client[fastembed]~=1.14.3", ] +milvus = [ + "pymilvus[milvus-lite]>=3.0.0,<4", +] aws = [ "boto3~=1.42.90", "aiobotocore~=3.5.0", diff --git a/lib/crewai/src/crewai/rag/config/optional_imports/base.py b/lib/crewai/src/crewai/rag/config/optional_imports/base.py index faeb85fe32..592fcc4cc4 100644 --- a/lib/crewai/src/crewai/rag/config/optional_imports/base.py +++ b/lib/crewai/src/crewai/rag/config/optional_imports/base.py @@ -14,7 +14,7 @@ class _MissingProvider: Raises RuntimeError when instantiated to indicate missing dependencies. """ - provider: Literal["chromadb", "qdrant", "__missing__"] = field( + provider: Literal["chromadb", "milvus", "qdrant", "__missing__"] = field( default="__missing__" ) diff --git a/lib/crewai/src/crewai/rag/config/optional_imports/protocols.py b/lib/crewai/src/crewai/rag/config/optional_imports/protocols.py index a4881f24af..2dd44d787f 100644 --- a/lib/crewai/src/crewai/rag/config/optional_imports/protocols.py +++ b/lib/crewai/src/crewai/rag/config/optional_imports/protocols.py @@ -8,6 +8,8 @@ if TYPE_CHECKING: from crewai.rag.chromadb.client import ChromaDBClient from crewai.rag.chromadb.config import ChromaDBConfig + from crewai.rag.milvus.client import MilvusClient + from crewai.rag.milvus.config import MilvusConfig from crewai.rag.qdrant.client import QdrantClient from crewai.rag.qdrant.config import QdrantConfig @@ -26,3 +28,11 @@ class QdrantFactoryModule(Protocol): def create_client(self, config: QdrantConfig) -> QdrantClient: """Creates a Qdrant client from configuration.""" ... + + +class MilvusFactoryModule(Protocol): + """Protocol for Milvus factory module.""" + + def create_client(self, config: MilvusConfig) -> MilvusClient: + """Creates a Milvus client from configuration.""" + ... diff --git a/lib/crewai/src/crewai/rag/config/optional_imports/providers.py b/lib/crewai/src/crewai/rag/config/optional_imports/providers.py index a7101b6985..d6eea5c611 100644 --- a/lib/crewai/src/crewai/rag/config/optional_imports/providers.py +++ b/lib/crewai/src/crewai/rag/config/optional_imports/providers.py @@ -16,6 +16,13 @@ class MissingChromaDBConfig(_MissingProvider): provider: Literal["chromadb"] = field(default="chromadb") +@pyd_dataclass(config=ConfigDict(extra="forbid")) +class MissingMilvusConfig(_MissingProvider): + """Placeholder for missing Milvus configuration.""" + + provider: Literal["milvus"] = field(default="milvus") + + @pyd_dataclass(config=ConfigDict(extra="forbid")) class MissingQdrantConfig(_MissingProvider): """Placeholder for missing Qdrant configuration.""" diff --git a/lib/crewai/src/crewai/rag/config/optional_imports/types.py b/lib/crewai/src/crewai/rag/config/optional_imports/types.py index c4141e405a..2f892d10b6 100644 --- a/lib/crewai/src/crewai/rag/config/optional_imports/types.py +++ b/lib/crewai/src/crewai/rag/config/optional_imports/types.py @@ -4,6 +4,6 @@ SupportedProvider = Annotated[ - Literal["chromadb", "qdrant"], + Literal["chromadb", "milvus", "qdrant"], "Supported RAG provider types, add providers here as they become available", ] diff --git a/lib/crewai/src/crewai/rag/config/types.py b/lib/crewai/src/crewai/rag/config/types.py index 9e3cd78e73..2ba38a5d0e 100644 --- a/lib/crewai/src/crewai/rag/config/types.py +++ b/lib/crewai/src/crewai/rag/config/types.py @@ -10,8 +10,10 @@ # Linter freaks out on conditional imports, assigning in the type checking fixes it if TYPE_CHECKING: from crewai.rag.chromadb.config import ChromaDBConfig as ChromaDBConfig_ + from crewai.rag.milvus.config import MilvusConfig as MilvusConfig_ ChromaDBConfig = ChromaDBConfig_ + MilvusConfig = MilvusConfig_ from crewai.rag.qdrant.config import QdrantConfig as QdrantConfig_ QdrantConfig = QdrantConfig_ @@ -23,6 +25,13 @@ MissingChromaDBConfig as ChromaDBConfig, ) + try: + from crewai.rag.milvus.config import MilvusConfig + except ImportError: + from crewai.rag.config.optional_imports.providers import ( + MissingMilvusConfig as MilvusConfig, + ) + try: from crewai.rag.qdrant.config import QdrantConfig except ImportError: @@ -30,7 +39,7 @@ MissingQdrantConfig as QdrantConfig, ) -SupportedProviderConfig: TypeAlias = ChromaDBConfig | QdrantConfig +SupportedProviderConfig: TypeAlias = ChromaDBConfig | MilvusConfig | QdrantConfig RagConfigType: TypeAlias = Annotated[ SupportedProviderConfig, Field(discriminator=DISCRIMINATOR) ] diff --git a/lib/crewai/src/crewai/rag/factory.py b/lib/crewai/src/crewai/rag/factory.py index 0993c445e9..e4c5e55599 100644 --- a/lib/crewai/src/crewai/rag/factory.py +++ b/lib/crewai/src/crewai/rag/factory.py @@ -5,6 +5,7 @@ from crewai.rag.config.optional_imports.protocols import ( ChromaFactoryModule, + MilvusFactoryModule, QdrantFactoryModule, ) from crewai.rag.config.types import RagConfigType @@ -75,4 +76,14 @@ def create_client(config: RagConfigType) -> BaseClient: ) return qdrant_mod.create_client(config) + if config.provider == "milvus": + milvus_mod = cast( + MilvusFactoryModule, + require( + "crewai.rag.milvus.factory", + purpose="The 'milvus' provider", + ), + ) + return milvus_mod.create_client(config) + raise ValueError(f"Unsupported provider: {config.provider}") diff --git a/lib/crewai/src/crewai/rag/milvus/__init__.py b/lib/crewai/src/crewai/rag/milvus/__init__.py new file mode 100644 index 0000000000..8177684889 --- /dev/null +++ b/lib/crewai/src/crewai/rag/milvus/__init__.py @@ -0,0 +1 @@ +"""Milvus RAG provider.""" diff --git a/lib/crewai/src/crewai/rag/milvus/client.py b/lib/crewai/src/crewai/rag/milvus/client.py new file mode 100644 index 0000000000..16844079ce --- /dev/null +++ b/lib/crewai/src/crewai/rag/milvus/client.py @@ -0,0 +1,318 @@ +"""Milvus client implementation.""" + +import asyncio +from typing import Any, cast + +from typing_extensions import Unpack + +from crewai.rag.core.base_client import ( + BaseClient, + BaseCollectionAddParams, + BaseCollectionParams, + BaseCollectionSearchParams, +) +from crewai.rag.core.exceptions import ClientMethodMismatchError +from crewai.rag.milvus.types import ( + AsyncEmbeddingFunction, + EmbeddingFunction, + MilvusCollectionCreateParams, +) +from crewai.rag.milvus.utils import ( + _ensure_collection, + _ensure_list_embedding, + _is_async_embedding_function, + _is_sync_client, + _prepare_documents_for_milvus, + _prepare_search_params, + _process_search_results, + _validate_collection_schema, + _validate_metric_type, +) +from crewai.rag.types import SearchResult + + +class MilvusClient(BaseClient): + """Milvus implementation of the BaseClient protocol.""" + + def __init__( + self, + client: Any, + embedding_function: EmbeddingFunction | AsyncEmbeddingFunction, + default_limit: int = 5, + default_score_threshold: float = 0.6, + default_batch_size: int = 100, + dimension: int = 1536, + metric_type: str = "COSINE", + consistency_level: str | None = None, + ) -> None: + """Initialize MilvusClient with client and embedding function.""" + self.client = client + self.embedding_function = embedding_function + self.default_limit = default_limit + self.default_score_threshold = default_score_threshold + self.default_batch_size = default_batch_size + self.dimension = dimension + self.metric_type = _validate_metric_type(metric_type) + self.consistency_level = consistency_level + + def create_collection(self, **kwargs: Unpack[MilvusCollectionCreateParams]) -> None: + """Create a new collection in Milvus.""" + if not _is_sync_client(self.client): + raise ClientMethodMismatchError( + method_name="create_collection", + expected_client="MilvusClient", + alt_method="acreate_collection", + alt_client="MilvusClient", + ) + + collection_name = kwargs["collection_name"] + if self.client.has_collection(collection_name=collection_name): + raise ValueError(f"Collection '{collection_name}' already exists") + + dimension = kwargs.get("dimension", self.dimension) + metric_type = _validate_metric_type(kwargs.get("metric_type", self.metric_type)) + consistency_level = kwargs.get("consistency_level", self.consistency_level) + _ensure_collection( + client=self.client, + collection_name=collection_name, + dimension=dimension, + metric_type=metric_type, + consistency_level=consistency_level, + ) + + async def acreate_collection( + self, **kwargs: Unpack[MilvusCollectionCreateParams] + ) -> None: + """Create a new collection in Milvus asynchronously.""" + await asyncio.to_thread(self.create_collection, **kwargs) + + def get_or_create_collection( + self, **kwargs: Unpack[MilvusCollectionCreateParams] + ) -> Any: + """Get an existing collection or create it if it doesn't exist.""" + if not _is_sync_client(self.client): + raise ClientMethodMismatchError( + method_name="get_or_create_collection", + expected_client="MilvusClient", + alt_method="aget_or_create_collection", + alt_client="MilvusClient", + ) + + collection_name = kwargs["collection_name"] + dimension = kwargs.get("dimension", self.dimension) + metric_type = _validate_metric_type(kwargs.get("metric_type", self.metric_type)) + consistency_level = kwargs.get("consistency_level", self.consistency_level) + _ensure_collection( + client=self.client, + collection_name=collection_name, + dimension=dimension, + metric_type=metric_type, + consistency_level=consistency_level, + ) + return self.client.describe_collection(collection_name=collection_name) + + async def aget_or_create_collection( + self, **kwargs: Unpack[MilvusCollectionCreateParams] + ) -> Any: + """Get an existing collection or create it if it doesn't exist asynchronously.""" + return await asyncio.to_thread(self.get_or_create_collection, **kwargs) + + def add_documents(self, **kwargs: Unpack[BaseCollectionAddParams]) -> None: + """Add documents with their embeddings to a collection.""" + if not _is_sync_client(self.client): + raise ClientMethodMismatchError( + method_name="add_documents", + expected_client="MilvusClient", + alt_method="aadd_documents", + alt_client="MilvusClient", + ) + + collection_name = kwargs["collection_name"] + documents = kwargs["documents"] + batch_size = kwargs.get("batch_size", self.default_batch_size) + + if not documents: + raise ValueError("Documents list cannot be empty") + + if not self.client.has_collection(collection_name=collection_name): + raise ValueError(f"Collection '{collection_name}' does not exist") + _validate_collection_schema( + client=self.client, + collection_name=collection_name, + dimension=self.dimension, + ) + + for i in range(0, len(documents), batch_size): + batch_docs = documents[i : min(i + batch_size, len(documents))] + embeddings: list[list[float]] = [] + for doc in batch_docs: + if _is_async_embedding_function(self.embedding_function): + raise TypeError( + "Async embedding function cannot be used with sync " + "add_documents. Use aadd_documents instead." + ) + sync_fn = cast(EmbeddingFunction, self.embedding_function) + embeddings.append(_ensure_list_embedding(sync_fn(doc["content"]))) + + data = _prepare_documents_for_milvus( + documents=batch_docs, + embeddings=embeddings, + ) + self.client.upsert(collection_name=collection_name, data=data) + + async def aadd_documents(self, **kwargs: Unpack[BaseCollectionAddParams]) -> None: + """Add documents with their embeddings to a collection asynchronously.""" + collection_name = kwargs["collection_name"] + documents = kwargs["documents"] + batch_size = kwargs.get("batch_size", self.default_batch_size) + + if not documents: + raise ValueError("Documents list cannot be empty") + + if not self.client.has_collection(collection_name=collection_name): + raise ValueError(f"Collection '{collection_name}' does not exist") + _validate_collection_schema( + client=self.client, + collection_name=collection_name, + dimension=self.dimension, + ) + + for i in range(0, len(documents), batch_size): + batch_docs = documents[i : min(i + batch_size, len(documents))] + embeddings: list[list[float]] = [] + for doc in batch_docs: + if _is_async_embedding_function(self.embedding_function): + embeddings.append( + _ensure_list_embedding( + await self.embedding_function(doc["content"]) + ) + ) + else: + sync_fn = cast(EmbeddingFunction, self.embedding_function) + embeddings.append(_ensure_list_embedding(sync_fn(doc["content"]))) + + data = _prepare_documents_for_milvus( + documents=batch_docs, + embeddings=embeddings, + ) + await asyncio.to_thread( + self.client.upsert, + collection_name=collection_name, + data=data, + ) + + def search( + self, **kwargs: Unpack[BaseCollectionSearchParams] + ) -> list[SearchResult]: + """Search for similar documents using a query.""" + if not _is_sync_client(self.client): + raise ClientMethodMismatchError( + method_name="search", + expected_client="MilvusClient", + alt_method="asearch", + alt_client="MilvusClient", + ) + + collection_name = kwargs["collection_name"] + query = kwargs["query"] + limit = kwargs.get("limit", self.default_limit) + metadata_filter = kwargs.get("metadata_filter") + score_threshold = kwargs.get("score_threshold", self.default_score_threshold) + + if not self.client.has_collection(collection_name=collection_name): + raise ValueError(f"Collection '{collection_name}' does not exist") + + if _is_async_embedding_function(self.embedding_function): + raise TypeError( + "Async embedding function cannot be used with sync search. " + "Use asearch instead." + ) + sync_fn = cast(EmbeddingFunction, self.embedding_function) + query_embedding = _ensure_list_embedding(sync_fn(query)) + + search_kwargs = _prepare_search_params( + collection_name=collection_name, + query_embedding=query_embedding, + limit=limit, + metric_type=self.metric_type, + metadata_filter=metadata_filter, + ) + response = self.client.search(**search_kwargs) + return _process_search_results( + response=response, + metric_type=self.metric_type, + score_threshold=score_threshold, + ) + + async def asearch( + self, **kwargs: Unpack[BaseCollectionSearchParams] + ) -> list[SearchResult]: + """Search for similar documents using a query asynchronously.""" + collection_name = kwargs["collection_name"] + query = kwargs["query"] + limit = kwargs.get("limit", self.default_limit) + metadata_filter = kwargs.get("metadata_filter") + score_threshold = kwargs.get("score_threshold", self.default_score_threshold) + + if not self.client.has_collection(collection_name=collection_name): + raise ValueError(f"Collection '{collection_name}' does not exist") + + if _is_async_embedding_function(self.embedding_function): + query_embedding = _ensure_list_embedding( + await self.embedding_function(query) + ) + else: + sync_fn = cast(EmbeddingFunction, self.embedding_function) + query_embedding = _ensure_list_embedding(sync_fn(query)) + + search_kwargs = _prepare_search_params( + collection_name=collection_name, + query_embedding=query_embedding, + limit=limit, + metric_type=self.metric_type, + metadata_filter=metadata_filter, + ) + response = await asyncio.to_thread(self.client.search, **dict(search_kwargs)) + return _process_search_results( + response=response, + metric_type=self.metric_type, + score_threshold=score_threshold, + ) + + def delete_collection(self, **kwargs: Unpack[BaseCollectionParams]) -> None: + """Delete a collection and all its data.""" + if not _is_sync_client(self.client): + raise ClientMethodMismatchError( + method_name="delete_collection", + expected_client="MilvusClient", + alt_method="adelete_collection", + alt_client="MilvusClient", + ) + + collection_name = kwargs["collection_name"] + + if not self.client.has_collection(collection_name=collection_name): + raise ValueError(f"Collection '{collection_name}' does not exist") + + self.client.drop_collection(collection_name=collection_name) + + async def adelete_collection(self, **kwargs: Unpack[BaseCollectionParams]) -> None: + """Delete a collection and all its data asynchronously.""" + await asyncio.to_thread(self.delete_collection, **kwargs) + + def reset(self) -> None: + """Reset the vector database by deleting all collections and data.""" + if not _is_sync_client(self.client): + raise ClientMethodMismatchError( + method_name="reset", + expected_client="MilvusClient", + alt_method="areset", + alt_client="MilvusClient", + ) + + for collection_name in self.client.list_collections(): + self.client.drop_collection(collection_name=collection_name) + + async def areset(self) -> None: + """Reset the vector database by deleting all collections asynchronously.""" + await asyncio.to_thread(self.reset) diff --git a/lib/crewai/src/crewai/rag/milvus/config.py b/lib/crewai/src/crewai/rag/milvus/config.py new file mode 100644 index 0000000000..97658efde3 --- /dev/null +++ b/lib/crewai/src/crewai/rag/milvus/config.py @@ -0,0 +1,58 @@ +"""Milvus configuration model.""" + +from __future__ import annotations + +from dataclasses import field +import os +from typing import Literal, cast + +from pydantic.dataclasses import dataclass as pyd_dataclass + +from crewai.rag.config.base import BaseRagConfig +from crewai.rag.milvus.constants import ( + DEFAULT_DIMENSION, + DEFAULT_EMBEDDING_MODEL, + DEFAULT_URI, +) +from crewai.rag.milvus.types import ( + MilvusClientParams, + MilvusConsistencyLevel, + MilvusEmbeddingFunctionWrapper, + MilvusMetricType, +) + + +def _default_options() -> MilvusClientParams: + """Create default Milvus client options.""" + return MilvusClientParams(uri=DEFAULT_URI) + + +def _default_embedding_function() -> MilvusEmbeddingFunctionWrapper: + """Create default Milvus embedding function using OpenAI embeddings.""" + from openai import OpenAI + + client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) + + def embed_fn(text: str) -> list[float]: + """Embed a single text string.""" + response = client.embeddings.create( + input=text, + model=DEFAULT_EMBEDDING_MODEL, + ) + return response.data[0].embedding + + return cast(MilvusEmbeddingFunctionWrapper, embed_fn) + + +@pyd_dataclass(frozen=True) +class MilvusConfig(BaseRagConfig): + """Configuration for Milvus client.""" + + provider: Literal["milvus"] = field(default="milvus", init=False) + options: MilvusClientParams = field(default_factory=_default_options) + embedding_function: MilvusEmbeddingFunctionWrapper = field( + default_factory=_default_embedding_function + ) + dimension: int = DEFAULT_DIMENSION + metric_type: MilvusMetricType = field(default="COSINE") + consistency_level: MilvusConsistencyLevel | None = None diff --git a/lib/crewai/src/crewai/rag/milvus/constants.py b/lib/crewai/src/crewai/rag/milvus/constants.py new file mode 100644 index 0000000000..e32aeb3f38 --- /dev/null +++ b/lib/crewai/src/crewai/rag/milvus/constants.py @@ -0,0 +1,18 @@ +"""Constants for Milvus RAG provider.""" + +from typing import Final + + +DEFAULT_URI: Final[str] = "./milvus.db" +DEFAULT_EMBEDDING_MODEL: Final[str] = "text-embedding-3-small" +DEFAULT_DIMENSION: Final[int] = 1536 +DEFAULT_METRIC_TYPE: Final[str] = "COSINE" +DEFAULT_ID_MAX_LENGTH: Final[int] = 512 +DEFAULT_CONTENT_MAX_LENGTH: Final[int] = 65535 +VALID_CONSISTENCY_LEVELS: Final[set[str]] = { + "Strong", + "Session", + "Bounded", + "Eventually", +} +VALID_METRIC_TYPES: Final[set[str]] = {"COSINE", "IP", "L2"} diff --git a/lib/crewai/src/crewai/rag/milvus/factory.py b/lib/crewai/src/crewai/rag/milvus/factory.py new file mode 100644 index 0000000000..007b15d68a --- /dev/null +++ b/lib/crewai/src/crewai/rag/milvus/factory.py @@ -0,0 +1,29 @@ +"""Factory functions for creating Milvus clients from configuration.""" + +from pymilvus import MilvusClient as SyncMilvusClient # type: ignore[import-untyped] + +from crewai.rag.milvus.client import MilvusClient +from crewai.rag.milvus.config import MilvusConfig + + +def create_client(config: MilvusConfig) -> MilvusClient: + """Create a Milvus client from configuration. + + Args: + config: The Milvus configuration. + + Returns: + A configured MilvusClient instance. + """ + + milvus_client = SyncMilvusClient(**config.options) + return MilvusClient( + client=milvus_client, + embedding_function=config.embedding_function, + default_limit=config.limit, + default_score_threshold=config.score_threshold, + default_batch_size=config.batch_size, + dimension=config.dimension, + metric_type=config.metric_type, + consistency_level=config.consistency_level, + ) diff --git a/lib/crewai/src/crewai/rag/milvus/types.py b/lib/crewai/src/crewai/rag/milvus/types.py new file mode 100644 index 0000000000..48d0f49d5b --- /dev/null +++ b/lib/crewai/src/crewai/rag/milvus/types.py @@ -0,0 +1,79 @@ +"""Type definitions for the Milvus RAG provider.""" + +from typing import Annotated, Any, Literal, Protocol, TypeAlias + +from pydantic import GetCoreSchemaHandler +from pydantic_core import CoreSchema, core_schema +from typing_extensions import NotRequired, TypedDict + +from crewai.rag.core.base_client import BaseCollectionParams + + +QueryEmbedding: TypeAlias = list[float] +MilvusMetricType: TypeAlias = Literal["COSINE", "IP", "L2"] +MilvusConsistencyLevel: TypeAlias = Literal[ + "Strong", "Session", "Bounded", "Eventually" +] + + +class EmbeddingFunction(Protocol): + """Protocol for embedding functions that convert text to vectors.""" + + def __call__(self, text: str) -> QueryEmbedding: + """Convert text to an embedding vector.""" + ... + + +class AsyncEmbeddingFunction(Protocol): + """Protocol for async embedding functions that convert text to vectors.""" + + async def __call__(self, text: str) -> QueryEmbedding: + """Convert text to an embedding vector asynchronously.""" + ... + + +class MilvusEmbeddingFunctionWrapper(EmbeddingFunction): + """Base class for Milvus embedding functions used by Pydantic validation.""" + + @classmethod + def __get_pydantic_core_schema__( + cls, _source_type: Any, _handler: GetCoreSchemaHandler + ) -> CoreSchema: + """Generate Pydantic core schema for Milvus embedding functions.""" + return core_schema.callable_schema() + + +class MilvusClientParams(TypedDict, total=False): + """Parameters for pymilvus.MilvusClient initialization.""" + + uri: str + token: str + db_name: str + user: str + password: str + timeout: float | None + + +class MilvusCollectionCreateParams(BaseCollectionParams, total=False): + """Collection creation parameters for Milvus.""" + + dimension: Annotated[int, "Vector dimension for the collection"] + metric_type: Annotated[MilvusMetricType, "Vector metric type"] + consistency_level: MilvusConsistencyLevel + + +class PreparedSearchParams(TypedDict): + """Parameters for MilvusClient.search.""" + + collection_name: str + data: list[list[float]] + limit: int + output_fields: list[str] + search_params: dict[str, Any] + filter: NotRequired[str] + + +MilvusMetadataValue: TypeAlias = str | int | float | bool +MilvusMetadataFilter: TypeAlias = dict[str, MilvusMetadataValue] +MilvusSearchHit: TypeAlias = dict[str, Any] +MilvusSearchResponse: TypeAlias = list[list[MilvusSearchHit]] diff --git a/lib/crewai/src/crewai/rag/milvus/utils.py b/lib/crewai/src/crewai/rag/milvus/utils.py new file mode 100644 index 0000000000..56910878a1 --- /dev/null +++ b/lib/crewai/src/crewai/rag/milvus/utils.py @@ -0,0 +1,417 @@ +"""Utility functions for Milvus operations.""" + +from __future__ import annotations + +import asyncio +import hashlib +import importlib.metadata +import json +import math +import re +from typing import Any, TypeGuard, cast + +from pymilvus import ( # type: ignore[import-untyped] + DataType, + MilvusClient as SyncMilvusClient, +) + +from crewai.rag.milvus.constants import ( + DEFAULT_CONTENT_MAX_LENGTH, + DEFAULT_ID_MAX_LENGTH, + VALID_METRIC_TYPES, +) +from crewai.rag.milvus.types import ( + AsyncEmbeddingFunction, + EmbeddingFunction, + MilvusMetadataFilter, + MilvusMetricType, + MilvusSearchResponse, + PreparedSearchParams, + QueryEmbedding, +) +from crewai.rag.types import BaseRecord, SearchResult + + +METADATA_FIELD_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +RESERVED_METADATA_FIELDS = {"id", "vector", "content", "metadata"} +MILVUS_LITE_COSINE_DISTANCE_VERSIONS = {"3.0", "3.0.0"} + + +def _ensure_list_embedding(embedding: QueryEmbedding | Any) -> list[float]: + """Convert embedding to list[float] format if needed.""" + if isinstance(embedding, list): + return [float(value) for value in embedding] + + if hasattr(embedding, "tolist"): + value = embedding.tolist() + if isinstance(value, list): + return [float(item) for item in value] + + raise TypeError("Embedding function must return a list of floats") + + +def _is_sync_client(client: object) -> bool: + """Type guard to check if the client is a synchronous MilvusClient.""" + return isinstance(client, SyncMilvusClient) + + +def _is_async_embedding_function( + func: EmbeddingFunction | AsyncEmbeddingFunction, +) -> TypeGuard[AsyncEmbeddingFunction]: + """Type guard to check if the embedding function is async.""" + return asyncio.iscoroutinefunction(func) + + +def _validate_metric_type(metric_type: str) -> MilvusMetricType: + """Validate and normalize a Milvus metric type.""" + normalized = metric_type.upper() + if normalized not in VALID_METRIC_TYPES: + raise ValueError( + f"Unsupported Milvus metric type '{metric_type}'. " + f"Expected one of {sorted(VALID_METRIC_TYPES)}" + ) + return cast(MilvusMetricType, normalized) + + +def _create_schema(client: Any, dimension: int) -> Any: + """Create the explicit Milvus schema for CrewAI RAG documents.""" + schema = client.create_schema(auto_id=False, enable_dynamic_field=False) + schema.add_field( + field_name="id", + datatype=DataType.VARCHAR, + is_primary=True, + max_length=DEFAULT_ID_MAX_LENGTH, + ) + schema.add_field( + field_name="vector", + datatype=DataType.FLOAT_VECTOR, + dim=dimension, + ) + schema.add_field( + field_name="content", + datatype=DataType.VARCHAR, + max_length=DEFAULT_CONTENT_MAX_LENGTH, + ) + schema.add_field(field_name="metadata", datatype=DataType.JSON) + return schema + + +def _create_index_params(client: Any, metric_type: str) -> Any: + """Create AUTOINDEX parameters for Milvus collection fields.""" + index_params = client.prepare_index_params() + index_params.add_index( + field_name="vector", + index_type="AUTOINDEX", + metric_type=metric_type, + ) + return index_params + + +def _create_collection( + client: Any, + collection_name: str, + dimension: int, + metric_type: str, + consistency_level: str | None, +) -> None: + """Create a Milvus collection with the provider schema and index.""" + schema = _create_schema(client=client, dimension=dimension) + index_params = _create_index_params(client=client, metric_type=metric_type) + create_kwargs: dict[str, Any] = { + "collection_name": collection_name, + "schema": schema, + "index_params": index_params, + } + if consistency_level is not None: + create_kwargs["consistency_level"] = consistency_level + + client.create_collection(**create_kwargs) + + +def _field_matches_type(field: dict[str, Any], expected: Any) -> bool: + """Return whether a described Milvus field matches the expected DataType.""" + value = field.get("type") + if value == expected or value == expected.value: + return True + if getattr(value, "name", None) == expected.name: + return True + return str(value) in {expected.name, str(expected.value)} + + +def _field_param_int(field: dict[str, Any], key: str) -> int | None: + """Extract an integer field parameter from a Milvus field description.""" + params = field.get("params") + if not isinstance(params, dict) or key not in params: + return None + value = params[key] + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _validate_collection_schema( + client: Any, + collection_name: str, + dimension: int, +) -> None: + """Validate an existing Milvus collection before reusing it.""" + description = client.describe_collection(collection_name=collection_name) + fields = { + field.get("name"): field + for field in description.get("fields", []) + if isinstance(field, dict) + } + + id_field = fields.get("id") + vector_field = fields.get("vector") + content_field = fields.get("content") + metadata_field = fields.get("metadata") + + if not id_field or not id_field.get("is_primary"): + raise ValueError(f"Collection '{collection_name}' must have primary field 'id'") + if not _field_matches_type(id_field, DataType.VARCHAR): + raise ValueError(f"Collection '{collection_name}' field 'id' must be VARCHAR") + if _field_param_int(id_field, "max_length") != DEFAULT_ID_MAX_LENGTH: + raise ValueError( + f"Collection '{collection_name}' field 'id' must use max_length " + f"{DEFAULT_ID_MAX_LENGTH}" + ) + + if not vector_field or not _field_matches_type(vector_field, DataType.FLOAT_VECTOR): + raise ValueError( + f"Collection '{collection_name}' must have FLOAT_VECTOR field 'vector'" + ) + if _field_param_int(vector_field, "dim") != dimension: + raise ValueError( + f"Collection '{collection_name}' vector dimension does not match " + f"configured dimension {dimension}" + ) + + if not content_field or not _field_matches_type(content_field, DataType.VARCHAR): + raise ValueError( + f"Collection '{collection_name}' must have VARCHAR field 'content'" + ) + if _field_param_int(content_field, "max_length") != DEFAULT_CONTENT_MAX_LENGTH: + raise ValueError( + f"Collection '{collection_name}' field 'content' must use max_length " + f"{DEFAULT_CONTENT_MAX_LENGTH}" + ) + + if not metadata_field or not _field_matches_type(metadata_field, DataType.JSON): + raise ValueError( + f"Collection '{collection_name}' must have JSON field 'metadata'" + ) + + +def _ensure_collection( + client: Any, + collection_name: str, + dimension: int, + metric_type: str, + consistency_level: str | None, +) -> None: + """Create a collection if needed, otherwise validate its schema.""" + if client.has_collection(collection_name=collection_name): + _validate_collection_schema( + client=client, + collection_name=collection_name, + dimension=dimension, + ) + return + + _create_collection( + client=client, + collection_name=collection_name, + dimension=dimension, + metric_type=metric_type, + consistency_level=consistency_level, + ) + + +def _normalize_metadata(metadata: Any) -> dict[str, Any]: + """Normalize BaseRecord metadata to a dictionary.""" + if isinstance(metadata, list): + metadata = metadata[0] if metadata else {} + if metadata is None: + return {} + if isinstance(metadata, dict): + return dict(metadata) + return dict(metadata) + + +def _document_id(doc: BaseRecord, metadata: dict[str, Any]) -> str: + """Return a stable document ID for a BaseRecord.""" + if "doc_id" in doc: + return str(doc["doc_id"]) + if "doc_id" in metadata: + return str(metadata["doc_id"]) + + content_for_hash = doc["content"] + if metadata: + metadata_str = json.dumps(metadata, sort_keys=True) + content_for_hash = f"{content_for_hash}|{metadata_str}" + return hashlib.sha256(content_for_hash.encode()).hexdigest() + + +def _prepare_documents_for_milvus( + documents: list[BaseRecord], + embeddings: list[list[float]], +) -> list[dict[str, Any]]: + """Prepare CrewAI documents for Milvus upsert.""" + if len(documents) != len(embeddings): + raise ValueError("Documents and embeddings must have the same length") + + rows_by_id: dict[str, dict[str, Any]] = {} + for doc, embedding in zip(documents, embeddings, strict=True): + metadata = _normalize_metadata(doc.get("metadata")) + doc_id = _document_id(doc=doc, metadata=metadata) + rows_by_id[doc_id] = { + "id": doc_id, + "vector": embedding, + "content": doc["content"], + "metadata": metadata, + } + + return list(rows_by_id.values()) + + +def _validate_metadata_field_name(field_name: str) -> None: + """Validate a metadata key before using it in a Milvus expression.""" + if not METADATA_FIELD_PATTERN.fullmatch(field_name): + raise ValueError( + "Milvus metadata filter keys must start with a letter or underscore " + "and contain only letters, numbers, and underscores" + ) + if field_name in RESERVED_METADATA_FIELDS: + raise ValueError( + f"Milvus metadata filter key '{field_name}' conflicts with a " + "reserved collection field" + ) + + +def _format_filter_value(value: Any) -> str: + """Format a supported scalar value for a Milvus filter expression.""" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError("Milvus metadata filter float values must be finite") + return repr(value) + if isinstance(value, str): + return json.dumps(value) + + raise ValueError( + "Milvus metadata filters support only string, integer, float, and " + "boolean values" + ) + + +def _build_filter_expression( + metadata_filter: MilvusMetadataFilter | None, +) -> str | None: + """Build a safe Milvus filter expression from metadata equality filters.""" + if not metadata_filter: + return None + + expressions: list[str] = [] + for key, value in metadata_filter.items(): + _validate_metadata_field_name(key) + formatted_value = _format_filter_value(value) + expressions.append(f'metadata["{key}"] == {formatted_value}') + + return " and ".join(expressions) + + +def _prepare_search_params( + collection_name: str, + query_embedding: QueryEmbedding, + limit: int, + metric_type: str, + metadata_filter: MilvusMetadataFilter | None, +) -> PreparedSearchParams: + """Prepare parameters for MilvusClient.search.""" + search_kwargs: PreparedSearchParams = { + "collection_name": collection_name, + "data": [_ensure_list_embedding(query_embedding)], + "limit": limit, + "output_fields": ["id", "content", "metadata"], + "search_params": {"metric_type": metric_type}, + } + + filter_expression = _build_filter_expression(metadata_filter) + if filter_expression: + search_kwargs["filter"] = filter_expression + + return search_kwargs + + +def _milvus_lite_uses_cosine_distance() -> bool: + """Return whether the installed Milvus Lite version reports cosine distance.""" + try: + version = importlib.metadata.version("milvus-lite") + except importlib.metadata.PackageNotFoundError: + return False + base_version = version.split("+", maxsplit=1)[0] + return base_version in MILVUS_LITE_COSINE_DISTANCE_VERSIONS + + +def _normalize_milvus_score(raw_score: float, metric_type: str) -> float: + """Normalize a Milvus raw score or distance to CrewAI's score contract.""" + normalized_metric = _validate_metric_type(metric_type) + if normalized_metric == "COSINE": + if _milvus_lite_uses_cosine_distance(): + score = 1.0 - 0.5 * raw_score + else: + score = (raw_score + 1.0) / 2.0 + elif normalized_metric == "L2": + score = 1.0 / (1.0 + raw_score) + else: + score = (raw_score + 1.0) / 2.0 + + return max(0.0, min(1.0, score)) + + +def _extract_hit_entity(hit: dict[str, Any]) -> dict[str, Any]: + """Extract the entity payload from a Milvus search hit.""" + entity = hit.get("entity") + return entity if isinstance(entity, dict) else {} + + +def _extract_hit_score(hit: dict[str, Any]) -> float: + """Extract a raw score from a Milvus search hit.""" + value = hit.get("distance", hit.get("score", 0.0)) + return float(value) + + +def _process_search_results( + response: MilvusSearchResponse, + metric_type: str, + score_threshold: float | None, +) -> list[SearchResult]: + """Convert Milvus search response into CrewAI SearchResult dictionaries.""" + results: list[SearchResult] = [] + hits = response[0] if response else [] + + for hit in hits: + entity = _extract_hit_entity(hit) + score = _normalize_milvus_score( + raw_score=_extract_hit_score(hit), + metric_type=metric_type, + ) + if score_threshold is not None and score < score_threshold: + continue + + metadata = entity.get("metadata") + result: SearchResult = { + "id": str(hit.get("id", entity.get("id", ""))), + "content": str(entity.get("content", "")), + "metadata": dict(metadata) if isinstance(metadata, dict) else {}, + "score": score, + } + results.append(result) + + results.sort(key=lambda result: result["score"], reverse=True) + return results diff --git a/lib/crewai/tests/rag/config/test_factory.py b/lib/crewai/tests/rag/config/test_factory.py index 47c02aadd5..d8715c6f2d 100644 --- a/lib/crewai/tests/rag/config/test_factory.py +++ b/lib/crewai/tests/rag/config/test_factory.py @@ -26,6 +26,26 @@ def test_create_client_chromadb(): mock_module.create_client.assert_called_once_with(mock_config) +def test_create_client_milvus(): + """Test Milvus client creation.""" + mock_config = Mock() + mock_config.provider = "milvus" + + with patch("crewai.rag.factory.require") as mock_require: + mock_module = Mock() + mock_client = Mock() + mock_module.create_client.return_value = mock_client + mock_require.return_value = mock_module + + result = create_client(mock_config) + + assert result == mock_client + mock_require.assert_called_once_with( + "crewai.rag.milvus.factory", purpose="The 'milvus' provider" + ) + mock_module.create_client.assert_called_once_with(mock_config) + + def test_create_client_unsupported_provider(): """Test unsupported provider raises ValueError.""" mock_config = Mock() diff --git a/lib/crewai/tests/rag/config/test_optional_imports.py b/lib/crewai/tests/rag/config/test_optional_imports.py index cf0217a3c4..14f5c61087 100644 --- a/lib/crewai/tests/rag/config/test_optional_imports.py +++ b/lib/crewai/tests/rag/config/test_optional_imports.py @@ -2,7 +2,10 @@ import pytest from crewai.rag.config.optional_imports.base import _MissingProvider -from crewai.rag.config.optional_imports.providers import MissingChromaDBConfig +from crewai.rag.config.optional_imports.providers import ( + MissingChromaDBConfig, + MissingMilvusConfig, +) def test_missing_provider_raises_runtime_error(): @@ -19,3 +22,11 @@ def test_missing_chromadb_config_raises_runtime_error(): RuntimeError, match="provider 'chromadb' requested but not installed" ): MissingChromaDBConfig() + + +def test_missing_milvus_config_raises_runtime_error(): + """Test that MissingMilvusConfig raises RuntimeError on instantiation.""" + with pytest.raises( + RuntimeError, match="provider 'milvus' requested but not installed" + ): + MissingMilvusConfig() diff --git a/lib/crewai/tests/rag/milvus/__init__.py b/lib/crewai/tests/rag/milvus/__init__.py new file mode 100644 index 0000000000..ed4f7fccfb --- /dev/null +++ b/lib/crewai/tests/rag/milvus/__init__.py @@ -0,0 +1 @@ +"""Milvus RAG provider tests.""" diff --git a/lib/crewai/tests/rag/milvus/test_client.py b/lib/crewai/tests/rag/milvus/test_client.py new file mode 100644 index 0000000000..7ea52f6924 --- /dev/null +++ b/lib/crewai/tests/rag/milvus/test_client.py @@ -0,0 +1,233 @@ +"""Tests for MilvusClient implementation.""" + +from collections.abc import Iterator +from pathlib import Path +from typing import Any +from uuid import uuid4 + +import pytest + +pymilvus = pytest.importorskip("pymilvus") + +from crewai.rag.milvus.client import MilvusClient +from crewai.rag.milvus.constants import DEFAULT_CONTENT_MAX_LENGTH +from crewai.rag.milvus.constants import DEFAULT_ID_MAX_LENGTH +from crewai.rag.milvus.utils import _build_filter_expression +from crewai.rag.types import BaseRecord + + +DataType = pymilvus.DataType +SyncMilvusClient = pymilvus.MilvusClient + + +def embed_text(text: str) -> list[float]: + """Return deterministic embeddings for Milvus Lite tests.""" + text = text.lower() + if "alpha" in text: + return [1.0, 0.0, 0.0] + if "beta" in text: + return [0.0, 1.0, 0.0] + return [0.0, 0.0, 1.0] + + +@pytest.fixture +def raw_client(tmp_path: Path) -> Iterator[Any]: + """Create a real Milvus Lite client.""" + client = SyncMilvusClient(uri=str(tmp_path / "milvus.db")) + yield client + for collection_name in client.list_collections(): + client.drop_collection(collection_name=collection_name) + if hasattr(client, "close"): + client.close() + + +@pytest.fixture +def client(raw_client: Any) -> MilvusClient: + """Create a CrewAI Milvus client with deterministic embeddings.""" + return MilvusClient( + client=raw_client, + embedding_function=embed_text, + dimension=3, + default_score_threshold=0.0, + ) + + +def unique_collection_name(prefix: str = "test_collection") -> str: + """Return a Milvus-compatible unique collection name.""" + return f"{prefix}_{uuid4().hex}" + + +def field_by_name(description: dict[str, Any], name: str) -> dict[str, Any]: + """Find a field description by field name.""" + return next(field for field in description["fields"] if field["name"] == name) + + +def test_get_or_create_collection_creates_explicit_schema(client: MilvusClient) -> None: + """Test that Milvus collections use the expected explicit schema.""" + collection_name = unique_collection_name() + + description = client.get_or_create_collection(collection_name=collection_name) + + assert description.get("auto_id") is False + id_field = field_by_name(description, "id") + vector_field = field_by_name(description, "vector") + content_field = field_by_name(description, "content") + metadata_field = field_by_name(description, "metadata") + + assert id_field["is_primary"] is True + assert id_field["type"] == DataType.VARCHAR + assert int(id_field["params"]["max_length"]) == DEFAULT_ID_MAX_LENGTH + assert vector_field["type"] == DataType.FLOAT_VECTOR + assert int(vector_field["params"]["dim"]) == 3 + assert content_field["type"] == DataType.VARCHAR + assert int(content_field["params"]["max_length"]) == DEFAULT_CONTENT_MAX_LENGTH + assert metadata_field["type"] == DataType.JSON + + +def test_add_documents_search_orders_scores_and_filters(client: MilvusClient) -> None: + """Test real Milvus Lite document upsert, search, ordering, and filtering.""" + collection_name = unique_collection_name() + client.get_or_create_collection(collection_name=collection_name) + + documents: list[BaseRecord] = [ + { + "doc_id": "alpha-doc", + "content": "Alpha reference", + "metadata": {"category": "alpha", "priority": 1, "active": True}, + }, + { + "doc_id": "beta-doc", + "content": "Beta reference", + "metadata": {"category": "beta", "priority": 2, "active": False}, + }, + ] + + client.add_documents(collection_name=collection_name, documents=documents) + + results = client.search( + collection_name=collection_name, + query="alpha query", + limit=2, + ) + + assert [result["id"] for result in results] == ["alpha-doc", "beta-doc"] + assert results[0]["score"] == pytest.approx(1.0) + assert results[0]["score"] > results[1]["score"] + assert results[0]["metadata"] == { + "category": "alpha", + "priority": 1, + "active": True, + } + + filtered = client.search( + collection_name=collection_name, + query="alpha query", + limit=2, + metadata_filter={"category": "alpha"}, + score_threshold=0.9, + ) + + assert [result["id"] for result in filtered] == ["alpha-doc"] + + +@pytest.mark.asyncio +async def test_async_methods_use_real_milvus_lite(client: MilvusClient) -> None: + """Test async Milvus operations against a real Milvus Lite database.""" + collection_name = unique_collection_name() + await client.aget_or_create_collection(collection_name=collection_name) + + await client.aadd_documents( + collection_name=collection_name, + documents=[ + { + "doc_id": "alpha-async", + "content": "Alpha async document", + "metadata": {"category": "alpha"}, + } + ], + ) + + results = await client.asearch( + collection_name=collection_name, + query="alpha query", + limit=1, + ) + + assert len(results) == 1 + assert results[0]["id"] == "alpha-async" + assert results[0]["score"] == pytest.approx(1.0) + + +def test_get_or_create_collection_rejects_schema_mismatch(raw_client: Any) -> None: + """Test that an existing collection with the wrong vector dimension is rejected.""" + collection_name = unique_collection_name() + schema = raw_client.create_schema(auto_id=False, enable_dynamic_field=False) + schema.add_field( + field_name="id", + datatype=DataType.VARCHAR, + is_primary=True, + max_length=DEFAULT_ID_MAX_LENGTH, + ) + schema.add_field(field_name="vector", datatype=DataType.FLOAT_VECTOR, dim=2) + schema.add_field( + field_name="content", + datatype=DataType.VARCHAR, + max_length=DEFAULT_CONTENT_MAX_LENGTH, + ) + schema.add_field(field_name="metadata", datatype=DataType.JSON) + index_params = raw_client.prepare_index_params() + index_params.add_index( + field_name="vector", + index_type="AUTOINDEX", + metric_type="COSINE", + ) + raw_client.create_collection( + collection_name=collection_name, + schema=schema, + index_params=index_params, + ) + + client = MilvusClient( + client=raw_client, + embedding_function=embed_text, + dimension=3, + ) + + with pytest.raises(ValueError, match="vector dimension does not match"): + client.get_or_create_collection(collection_name=collection_name) + + +def test_metadata_filter_expression_escapes_values() -> None: + """Test that scalar metadata values are escaped for Milvus expressions.""" + expression = _build_filter_expression( + { + "category": 'alpha "quoted"', + "priority": 1, + "ratio": 1.5, + "active": True, + } + ) + + assert expression == ( + 'metadata["category"] == "alpha \\"quoted\\"" and ' + 'metadata["priority"] == 1 and ' + 'metadata["ratio"] == 1.5 and ' + 'metadata["active"] == true' + ) + + +@pytest.mark.parametrize( + ("metadata_filter", "match"), + [ + ({"bad-key": "value"}, "filter keys"), + ({"content": "value"}, "reserved collection field"), + ({"category": ["alpha"]}, "support only"), + ], +) +def test_metadata_filter_rejects_unsafe_inputs( + metadata_filter: dict[str, Any], + match: str, +) -> None: + """Test safe metadata filter validation.""" + with pytest.raises(ValueError, match=match): + _build_filter_expression(metadata_filter) diff --git a/lib/crewai/tests/rag/milvus/test_config.py b/lib/crewai/tests/rag/milvus/test_config.py new file mode 100644 index 0000000000..d0e7268f8b --- /dev/null +++ b/lib/crewai/tests/rag/milvus/test_config.py @@ -0,0 +1,43 @@ +"""Tests for MilvusConfig.""" + +import pytest +from pydantic_core import ValidationError + +from crewai.rag.milvus.config import MilvusConfig + + +def test_milvus_config_defaults_to_lite_uri() -> None: + """Test that Milvus config defaults to a local Milvus Lite URI.""" + config = MilvusConfig(embedding_function=lambda text: [0.1, 0.2, 0.3]) + + assert config.provider == "milvus" + assert config.options == {"uri": "./milvus.db"} + assert config.dimension == 1536 + assert config.metric_type == "COSINE" + + +def test_milvus_config_accepts_server_options() -> None: + """Test that Milvus config accepts server and cloud connection options.""" + config = MilvusConfig( + options={ + "uri": "https://example.api.gcp-us-west1.zillizcloud.com", + "token": "test-token", + "db_name": "crew", + }, + embedding_function=lambda text: [0.1, 0.2, 0.3], + consistency_level="Bounded", + ) + + assert config.options["uri"].startswith("https://") + assert config.options["token"] == "test-token" + assert config.options["db_name"] == "crew" + assert config.consistency_level == "Bounded" + + +@pytest.mark.parametrize("embedding_function", [None, 42]) +def test_milvus_config_rejects_non_callable_embedding_function( + embedding_function: object, +) -> None: + """Test that Milvus config rejects invalid embedding functions.""" + with pytest.raises(ValidationError, match="callable"): + MilvusConfig(embedding_function=embedding_function) diff --git a/uv.lock b/uv.lock index c14eb71a85..787aaf0861 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-07-11T05:37:26.328112Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P3D" [options.exclude-newer-package] @@ -1052,7 +1052,7 @@ name = "coloredlogs" version = "15.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "humanfriendly", marker = "python_full_version < '3.11'" }, + { name = "humanfriendly" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } wheels = [ @@ -1150,7 +1150,7 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine == 's390x'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -1225,7 +1225,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 's390x'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -1393,6 +1393,9 @@ litellm = [ mem0 = [ { name = "mem0ai" }, ] +milvus = [ + { name = "pymilvus", extra = ["milvus-lite"] }, +] openpyxl = [ { name = "openpyxl" }, ] @@ -1463,6 +1466,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.11.9,<2.13" }, { name = "pydantic-settings", specifier = ">=2.10.1,<3" }, { name = "pyjwt", specifier = ">=2.13.0,<3" }, + { name = "pymilvus", extras = ["milvus-lite"], marker = "extra == 'milvus'", specifier = ">=3.0.0,<4" }, { name = "python-dotenv", specifier = ">=1.2.2,<2" }, { name = "pyyaml", specifier = "~=6.0" }, { name = "qdrant-client", extras = ["fastembed"], marker = "extra == 'qdrant'", specifier = "~=1.14.3" }, @@ -1474,7 +1478,7 @@ requires-dist = [ { name = "tomli-w", specifier = "~=1.1.0" }, { name = "voyageai", marker = "extra == 'voyageai'", specifier = "~=0.3.5" }, ] -provides-extras = ["a2a", "anthropic", "aws", "azure-ai-inference", "bedrock", "docling", "embeddings", "file-processing", "google-genai", "litellm", "mem0", "openpyxl", "pandas", "qdrant", "qdrant-edge", "tools", "voyageai", "watson"] +provides-extras = ["a2a", "anthropic", "aws", "azure-ai-inference", "bedrock", "docling", "embeddings", "file-processing", "google-genai", "litellm", "mem0", "milvus", "openpyxl", "pandas", "qdrant", "qdrant-edge", "tools", "voyageai", "watson"] [[package]] name = "crewai-cli" @@ -1877,34 +1881,34 @@ wheels = [ [package.optional-dependencies] cudart = [ - { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cuda-runtime" }, ] cufft = [ - { name = "nvidia-cufft", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cufft" }, ] cufile = [ - { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cufile" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cuda-cupti" }, ] curand = [ - { name = "nvidia-curand", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-curand" }, ] cusolver = [ - { name = "nvidia-cusolver", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cusolver" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cusparse" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-nvjitlink" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cuda-nvrtc" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-nvtx" }, ] [[package]] @@ -2429,7 +2433,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -2445,6 +2449,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] +[[package]] +name = "faiss-cpu" +version = "1.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/b0/48c083d01b7b68c463c1d56507147a9d733f791e1c469a77215a872a9fb5/faiss_cpu-1.14.3-cp310-abi3-macosx_14_0_arm64.whl", hash = "sha256:a9369863290a3f0e033757e4c10577b6ef7431f1cede394dabd0a137e4e2ed45", size = 4768290, upload-time = "2026-06-13T02:19:03.427Z" }, + { url = "https://files.pythonhosted.org/packages/ab/34/6b04ef5bae3eada6b5a9457d7875cce041c040d53c890815cbd1e9821c65/faiss_cpu-1.14.3-cp310-abi3-macosx_15_0_x86_64.whl", hash = "sha256:f9d0e84d909194f63f027bbd3c1e35e905e48c9345c2db6e6f24da09a6bc5906", size = 6925734, upload-time = "2026-06-13T02:19:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/b451af4b3c6dd18749ecfb58ccb68503b77e49c9aa4a89d950e9d521e058/faiss_cpu-1.14.3-cp310-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d734cfa9ac90b6a5dfed3a27cb706d05f22824703dafc3969b4e2071877a31c", size = 9661210, upload-time = "2026-06-13T02:19:06.99Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ed/57335bc18c9e18677587bec9bf070c675b29c8e683e13f4def0440731ca0/faiss_cpu-1.14.3-cp310-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8780b526c06e57aad90a8c4655dfba2ff1b3195bd600ff4499752fc45159c9fc", size = 18506292, upload-time = "2026-06-13T02:19:09.621Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d3/c6ca8c44a63b909e78aa8a69e14501c79c89613e62261d58455ea603d710/faiss_cpu-1.14.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b28ba083e8c02f2c9be03783402537fd3f00d27e68799c44bf88931500ee12ec", size = 11238800, upload-time = "2026-06-13T02:19:12.821Z" }, + { url = "https://files.pythonhosted.org/packages/93/5f/b405692913a301251749cb175cb3f564ed257fdaa80a22c9a36444d0095d/faiss_cpu-1.14.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cdcb90850cb4b7c27d270839b37bcc0dc8d1fb6a62d1e13053e51ac95061ca25", size = 19237637, upload-time = "2026-06-13T02:19:15.531Z" }, +] + [[package]] name = "faker" version = "40.22.0" @@ -3018,8 +3040,8 @@ name = "grpcio-health-checking" version = "1.71.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "grpcio", marker = "python_full_version < '3.11' or (python_full_version >= '3.13' and platform_machine != 's390x')" }, - { name = "protobuf", marker = "python_full_version < '3.11' or (python_full_version >= '3.13' and platform_machine != 's390x')" }, + { name = "grpcio" }, + { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/86/20994347ef36b7626fb74539f13128100dd8b7eaac67efc063264e6cdc80/grpcio_health_checking-1.71.2.tar.gz", hash = "sha256:1c21ece88c641932f432b573ef504b20603bdf030ad4e1ec35dd7fdb4ea02637", size = 16770, upload-time = "2025-06-28T04:24:08.768Z" } wheels = [ @@ -3223,7 +3245,7 @@ name = "humanfriendly" version = "10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyreadline3", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } wheels = [ @@ -4460,10 +4482,10 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine == 's390x'", ] dependencies = [ - { name = "jsonref", marker = "python_full_version < '3.12'" }, - { name = "mcp", extra = ["ws"], marker = "python_full_version < '3.12'" }, - { name = "pydantic", marker = "python_full_version < '3.12'" }, - { name = "python-dotenv", marker = "python_full_version < '3.12'" }, + { name = "jsonref" }, + { name = "mcp", extra = ["ws"] }, + { name = "pydantic" }, + { name = "python-dotenv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d0/28/64fc666fa5d86bb1b048c167975d4ea19210f9f8571b64b26563739774ac/mcpadapt-0.1.19.tar.gz", hash = "sha256:dfab84fc75cc84a49a40bd61079773b1faf840227b74b82c71a7755b9c1957c5", size = 4227721, upload-time = "2025-10-16T07:11:56.736Z" } wheels = [ @@ -4481,10 +4503,10 @@ resolution-markers = [ "python_full_version == '3.12.*' and platform_machine == 's390x'", ] dependencies = [ - { name = "jsonref", marker = "python_full_version >= '3.12'" }, - { name = "mcp", extra = ["ws"], marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, - { name = "python-dotenv", marker = "python_full_version >= '3.12'" }, + { name = "jsonref" }, + { name = "mcp", extra = ["ws"] }, + { name = "pydantic" }, + { name = "python-dotenv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e3/71/1bbbe157e55d30ab4a74fa878f6942cc0586e9820f03e03451a3d2297e9b/mcpadapt-0.1.20.tar.gz", hash = "sha256:4047c0da61e481dd0673a48936a427da9e6547c6cf0d580ff4e4761dcf058ed1", size = 4203656, upload-time = "2025-10-24T15:35:02.135Z" } wheels = [ @@ -4530,6 +4552,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/ab/46eac3d46c17427af579cb44ff1513b552d85b32998748f2c66248513955/mem0ai-2.0.4-py3-none-any.whl", hash = "sha256:920a39474bf4c2a5a7d4bb814e237aacd699c435d8e20db257c3b43568e7175e", size = 303753, upload-time = "2026-05-27T17:45:44.598Z" }, ] +[[package]] +name = "milvus-lite" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "faiss-cpu" }, + { name = "grpcio" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pyarrow" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/9a/d80d260e6fe1246818a8ef782c374ba9c6ca46ca3b987c14eabe914ef805/milvus_lite-3.0.tar.gz", hash = "sha256:2c35d0d046b1faae3402cde1fb73d65f51ee8c6aba65f54de1dda46f7bb18b9b", size = 589749, upload-time = "2026-05-13T07:14:05.827Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/42/dee08ae3bfc1731572f193d00b248c9370b0b9dff12becb0ffd8b2ee8d56/milvus_lite-3.0-py3-none-any.whl", hash = "sha256:d9a094eab84bdaa4253da3721482282c939da1cce6f4e1759f947e8d3e53406e", size = 230490, upload-time = "2026-05-13T07:14:00.816Z" }, +] + [[package]] name = "ml-dtypes" version = "0.5.4" @@ -5499,12 +5538,12 @@ name = "onnxruntime" version = "1.23.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coloredlogs", marker = "python_full_version < '3.11'" }, - { name = "flatbuffers", marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "protobuf", marker = "python_full_version < '3.11'" }, - { name = "sympy", marker = "python_full_version < '3.11'" }, + { name = "coloredlogs" }, + { name = "flatbuffers" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/35/d6/311b1afea060015b56c742f3531168c1644650767f27ef40062569960587/onnxruntime-1.23.2-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:a7730122afe186a784660f6ec5807138bf9d792fa1df76556b27307ea9ebcbe3", size = 17195934, upload-time = "2025-10-27T23:06:14.143Z" }, @@ -7048,6 +7087,29 @@ version = "2.10" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5d/ab/34ec41718af73c00119d0351b7a2531d2ebddb51833a36448fc7b862be60/pylatexenc-2.10.tar.gz", hash = "sha256:3dd8fd84eb46dc30bee1e23eaab8d8fb5a7f507347b23e5f38ad9675c84f40d3", size = 162597, upload-time = "2021-04-06T07:56:07.854Z" } +[[package]] +name = "pymilvus" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "grpcio" }, + { name = "orjson" }, + { name = "pandas" }, + { name = "protobuf" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "setuptools" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/c1/01647e61f3a82fd881382746b6dde3401d65b88cd4f75bd059901fb2392b/pymilvus-3.0.0-1-py3-none-any.whl", hash = "sha256:57c8e7c87fbbf579f122b4df893949bc78e50bca2988527864891bd544817b05", size = 344817, upload-time = "2026-05-07T14:57:45.235Z" }, +] + +[package.optional-dependencies] +milvus-lite = [ + { name = "milvus-lite", marker = "sys_platform != 'win32'" }, +] + [[package]] name = "pymongo" version = "4.17.0" @@ -8152,7 +8214,7 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine == 's390x'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -8216,7 +8278,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 's390x'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -9847,13 +9909,13 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine == 's390x'", ] dependencies = [ - { name = "authlib", marker = "python_full_version < '3.11' or (python_full_version >= '3.13' and platform_machine != 's390x')" }, - { name = "deprecation", marker = "python_full_version < '3.11' or (python_full_version >= '3.13' and platform_machine != 's390x')" }, - { name = "grpcio", marker = "python_full_version < '3.11' or (python_full_version >= '3.13' and platform_machine != 's390x')" }, - { name = "grpcio-health-checking", marker = "python_full_version < '3.11' or (python_full_version >= '3.13' and platform_machine != 's390x')" }, - { name = "httpx", marker = "python_full_version < '3.11' or (python_full_version >= '3.13' and platform_machine != 's390x')" }, - { name = "pydantic", marker = "python_full_version < '3.11' or (python_full_version >= '3.13' and platform_machine != 's390x')" }, - { name = "validators", marker = "python_full_version < '3.11' or (python_full_version >= '3.13' and platform_machine != 's390x')" }, + { name = "authlib" }, + { name = "deprecation" }, + { name = "grpcio" }, + { name = "grpcio-health-checking" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "validators" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/b9/7b9e05cf923743aa1479afcd85c48ebca82d031c3c3a5d02b1b3fcb52eb9/weaviate_client-4.16.2.tar.gz", hash = "sha256:eb7107a3221a5ad68d604cafc65195bd925a9709512ea0b6fe0dd212b0678fab", size = 681321, upload-time = "2025-07-22T09:10:48.79Z" } wheels = [ @@ -9872,13 +9934,13 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 's390x'", ] dependencies = [ - { name = "authlib", marker = "(python_full_version >= '3.11' and python_full_version < '3.13') or (python_full_version >= '3.11' and platform_machine == 's390x')" }, - { name = "grpcio", marker = "(python_full_version >= '3.11' and python_full_version < '3.13') or (python_full_version >= '3.11' and platform_machine == 's390x')" }, - { name = "httpx", marker = "(python_full_version >= '3.11' and python_full_version < '3.13') or (python_full_version >= '3.11' and platform_machine == 's390x')" }, - { name = "packaging", marker = "(python_full_version >= '3.11' and python_full_version < '3.13') or (python_full_version >= '3.11' and platform_machine == 's390x')" }, - { name = "protobuf", marker = "(python_full_version >= '3.11' and python_full_version < '3.13') or (python_full_version >= '3.11' and platform_machine == 's390x')" }, - { name = "pydantic", marker = "(python_full_version >= '3.11' and python_full_version < '3.13') or (python_full_version >= '3.11' and platform_machine == 's390x')" }, - { name = "validators", marker = "(python_full_version >= '3.11' and python_full_version < '3.13') or (python_full_version >= '3.11' and platform_machine == 's390x')" }, + { name = "authlib" }, + { name = "grpcio" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "validators" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2b/b8/103f3aaa246d4e932f4cfeb846e51436966f2aeedf60c2665a3fc51a975a/weaviate_client-4.21.3.tar.gz", hash = "sha256:d7b1f2b0cecbc747e9427f4e3b9463cdfee090746bfbbd40e59cfa25ea2afd4a", size = 847895, upload-time = "2026-06-02T13:03:51.598Z" } wheels = [