diff --git a/.gitignore b/.gitignore index 4570efd97..ea648f3e9 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,4 @@ next-env.d.ts # ignore adding self-signed certs certs/ +docker-compose.override.yml diff --git a/README.md b/README.md index ba8fd1a88..bcb86cdfe 100644 --- a/README.md +++ b/README.md @@ -342,6 +342,7 @@ docker-compose up | `openai` | OpenAI embeddings (default) | `OPENAI_API_KEY` | Uses `text-embedding-3-small` model | | `google` | Google AI embeddings | `GOOGLE_API_KEY` | Uses `text-embedding-004` model | | `ollama` | Local Ollama embeddings | None | Requires local Ollama installation | +| `voyage` | Voyage AI embeddings | `VOYAGE_AI_API_KEY` | Uses `voyage-code-3` model; optimized for code retrieval | ### Why Use Google AI Embeddings? @@ -363,6 +364,9 @@ export DEEPWIKI_EMBEDDER_TYPE=google # Use local Ollama embeddings export DEEPWIKI_EMBEDDER_TYPE=ollama + +# Use Voyage AI embeddings (optimized for code retrieval) +export DEEPWIKI_EMBEDDER_TYPE=voyage ``` **Note**: When switching embedders, you may need to regenerate your repository embeddings as different models produce different vector spaces. @@ -421,7 +425,8 @@ docker-compose up | `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint | No | Required only if you want to use Azure OpenAI models | | `AZURE_OPENAI_VERSION` | Azure OpenAI version | No | Required only if you want to use Azure OpenAI models | | `OLLAMA_HOST` | Ollama Host (default: http://localhost:11434) | No | Required only if you want to use external Ollama server | -| `DEEPWIKI_EMBEDDER_TYPE` | Embedder type: `openai`, `google`, `ollama`, or `bedrock` (default: `openai`) | No | Controls which embedding provider to use | +| `VOYAGE_AI_API_KEY` | Voyage AI API key | No | Required only if `DEEPWIKI_EMBEDDER_TYPE=voyage` | +| `DEEPWIKI_EMBEDDER_TYPE` | Embedder type: `openai`, `google`, `ollama`, `bedrock`, or `voyage` (default: `openai`) | No | Controls which embedding provider to use | | `PORT` | Port for the API server (default: 8001) | No | If you host API and frontend on the same machine, make sure change port of `SERVER_BASE_URL` accordingly | | `SERVER_BASE_URL` | Base URL for the API server (default: http://localhost:8001) | No | | `DEEPWIKI_AUTH_MODE` | Set to `true` or `1` to enable authorization mode. | No | Defaults to `false`. If enabled, `DEEPWIKI_AUTH_CODE` is required. | @@ -432,6 +437,7 @@ docker-compose up - If using `DEEPWIKI_EMBEDDER_TYPE=google`: `GOOGLE_API_KEY` is required - If using `DEEPWIKI_EMBEDDER_TYPE=ollama`: No API key required (local processing) - If using `DEEPWIKI_EMBEDDER_TYPE=bedrock`: AWS credentials (or role-based credentials) are required +- If using `DEEPWIKI_EMBEDDER_TYPE=voyage`: `VOYAGE_AI_API_KEY` is required Other API keys are only required when configuring and using models from the corresponding providers. diff --git a/api/config.py b/api/config.py index 49dfcf7b0..eacbb0dc9 100644 --- a/api/config.py +++ b/api/config.py @@ -13,12 +13,14 @@ from api.google_embedder_client import GoogleEmbedderClient from api.azureai_client import AzureAIClient from api.dashscope_client import DashscopeClient +from api.voyage_client import VoyageEmbedderClient from adalflow import GoogleGenAIClient, OllamaClient # Get API keys from environment variables OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY') GOOGLE_API_KEY = os.environ.get('GOOGLE_API_KEY') OPENROUTER_API_KEY = os.environ.get('OPENROUTER_API_KEY') +VOYAGE_AI_API_KEY = os.environ.get('VOYAGE_AI_API_KEY') AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID') AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY') AWS_SESSION_TOKEN = os.environ.get('AWS_SESSION_TOKEN') @@ -32,6 +34,9 @@ os.environ["GOOGLE_API_KEY"] = GOOGLE_API_KEY if OPENROUTER_API_KEY: os.environ["OPENROUTER_API_KEY"] = OPENROUTER_API_KEY +if VOYAGE_AI_API_KEY: + os.environ["VOYAGE_AI_API_KEY"] = VOYAGE_AI_API_KEY + os.environ["VOYAGE_API_KEY"] = VOYAGE_AI_API_KEY if AWS_ACCESS_KEY_ID: os.environ["AWS_ACCESS_KEY_ID"] = AWS_ACCESS_KEY_ID if AWS_SECRET_ACCESS_KEY: @@ -63,7 +68,8 @@ "OllamaClient": OllamaClient, "BedrockClient": BedrockClient, "AzureAIClient": AzureAIClient, - "DashscopeClient": DashscopeClient + "DashscopeClient": DashscopeClient, + "VoyageEmbedderClient": VoyageEmbedderClient } def replace_env_placeholders(config: Union[Dict[str, Any], List[Any], str, Any]) -> Union[Dict[str, Any], List[Any], str, Any]: @@ -152,7 +158,7 @@ def load_embedder_config(): embedder_config = load_json_config("embedder.json") # Process client classes - for key in ["embedder", "embedder_ollama", "embedder_google", "embedder_bedrock"]: + for key in ["embedder", "embedder_ollama", "embedder_google", "embedder_bedrock", "embedder_voyage"]: if key in embedder_config and "client_class" in embedder_config[key]: class_name = embedder_config[key]["client_class"] if class_name in CLIENT_CLASSES: @@ -174,6 +180,8 @@ def get_embedder_config(): return configs.get("embedder_google", {}) elif embedder_type == 'ollama' and 'embedder_ollama' in configs: return configs.get("embedder_ollama", {}) + elif embedder_type == 'voyage' and 'embedder_voyage' in configs: + return configs.get("embedder_voyage", {}) else: return configs.get("embedder", {}) @@ -235,12 +243,27 @@ def is_bedrock_embedder(): client_class = embedder_config.get("client_class", "") return client_class == "BedrockClient" +def is_voyage_embedder(): + """ + Check if the current embedder configuration uses VoyageEmbedderClient. + + Returns: + bool: True if using Voyage AI embeddings, False otherwise + """ + embedder_config = get_embedder_config() + if not embedder_config: + return False + model_client = embedder_config.get("model_client") + if model_client: + return model_client.__name__ == "VoyageEmbedderClient" + return embedder_config.get("client_class", "") == "VoyageEmbedderClient" + def get_embedder_type(): """ Get the current embedder type based on configuration. Returns: - str: 'bedrock', 'ollama', 'google', or 'openai' (default) + str: 'bedrock', 'ollama', 'google', 'voyage', or 'openai' (default) """ if is_bedrock_embedder(): return 'bedrock' @@ -248,6 +271,8 @@ def get_embedder_type(): return 'ollama' elif is_google_embedder(): return 'google' + elif is_voyage_embedder(): + return 'voyage' else: return 'openai' @@ -341,7 +366,7 @@ def load_lang_config(): # Update embedder configuration if embedder_config: - for key in ["embedder", "embedder_ollama", "embedder_google", "embedder_bedrock", "retriever", "text_splitter"]: + for key in ["embedder", "embedder_ollama", "embedder_google", "embedder_bedrock", "embedder_voyage", "retriever", "text_splitter"]: if key in embedder_config: configs[key] = embedder_config[key] diff --git a/api/config/embedder.json b/api/config/embedder.json index 0101ac083..4ac1fad69 100644 --- a/api/config/embedder.json +++ b/api/config/embedder.json @@ -30,6 +30,14 @@ "dimensions": 256 } }, + "embedder_voyage": { + "client_class": "VoyageEmbedderClient", + "batch_size": 100, + "model_kwargs": { + "model": "voyage-code-3", + "input_type": "document" + } + }, "retriever": { "top_k": 20 }, diff --git a/api/config/generator.json b/api/config/generator.json index f88179098..6f2bbbdd3 100644 --- a/api/config/generator.json +++ b/api/config/generator.json @@ -28,6 +28,11 @@ "top_p": 0.8, "top_k": 20 }, + "gemini-2.0-flash-exp": { + "temperature": 1.0, + "top_p": 0.8, + "top_k": 20 + }, "gemini-2.5-flash-lite": { "temperature": 1.0, "top_p": 0.8, diff --git a/api/data_pipeline.py b/api/data_pipeline.py index 5e1f5fa47..55c6458dc 100644 --- a/api/data_pipeline.py +++ b/api/data_pipeline.py @@ -30,7 +30,7 @@ def count_tokens(text: str, embedder_type: str = None, is_ollama_embedder: bool Args: text (str): The text to count tokens for. - embedder_type (str, optional): The embedder type ('openai', 'google', 'ollama', 'bedrock'). + embedder_type (str, optional): The embedder type ('openai', 'google', 'ollama', 'bedrock', 'voyage'). If None, will be determined from configuration. is_ollama_embedder (bool, optional): DEPRECATED. Use embedder_type instead. If None, will be determined from configuration. @@ -58,6 +58,9 @@ def count_tokens(text: str, embedder_type: str = None, is_ollama_embedder: bool elif embedder_type == 'bedrock': # Bedrock embedding models vary; use a common GPT-like encoding for rough estimation encoding = tiktoken.get_encoding("cl100k_base") + elif embedder_type == 'voyage': + # Voyage AI uses similar tokenization to GPT models for rough estimation + encoding = tiktoken.get_encoding("cl100k_base") else: # OpenAI or default # Use OpenAI embedding model encoding encoding = tiktoken.encoding_for_model("text-embedding-3-small") diff --git a/api/main.py b/api/main.py index fe083f550..ae9ae1fce 100644 --- a/api/main.py +++ b/api/main.py @@ -44,7 +44,7 @@ def patched_watch(*args, **kwargs): import uvicorn # Check for required environment variables -required_env_vars = ['GOOGLE_API_KEY', 'OPENAI_API_KEY'] +required_env_vars = ['GOOGLE_API_KEY'] missing_vars = [var for var in required_env_vars if not os.environ.get(var)] if missing_vars: logger.warning(f"Missing environment variables: {', '.join(missing_vars)}") diff --git a/api/poetry.lock b/api/poetry.lock index a2446bba9..a42218747 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -199,6 +199,18 @@ yarl = ">=1.17.0,<2.0" [package.extras] speedups = ["Brotli", "aiodns (>=3.3.0)", "backports.zstd", "brotlicffi"] +[[package]] +name = "aiolimiter" +version = "1.2.1" +description = "asyncio rate limiter, a leaky bucket implementation" +optional = false +python-versions = "<4.0,>=3.8" +groups = ["main"] +files = [ + {file = "aiolimiter-1.2.1-py3-none-any.whl", hash = "sha256:d3f249e9059a20badcb56b61601a83556133655c11d1eb3dd3e04ff069e5f3c7"}, + {file = "aiolimiter-1.2.1.tar.gz", hash = "sha256:e02a37ea1a855d9e832252a105420ad4d15011505512a1a1d814647451b5cca9"}, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -2811,6 +2823,22 @@ typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\"" [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] +[[package]] +name = "tenacity" +version = "9.1.4" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55"}, + {file = "tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + [[package]] name = "tiktoken" version = "0.12.0" @@ -3055,6 +3083,25 @@ dev = ["Cython (>=3.0,<4.0)", "setuptools (>=60)"] docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx_rtd_theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] test = ["aiohttp (>=3.10.5)", "flake8 (>=6.1,<7.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=25.3.0,<25.4.0)", "pycodestyle (>=2.11.0,<2.12.0)"] +[[package]] +name = "voyageai" +version = "0.2.4" +description = "" +optional = false +python-versions = "<4.0.0,>=3.7.1" +groups = ["main"] +files = [ + {file = "voyageai-0.2.4-py3-none-any.whl", hash = "sha256:e3070e5c78dec89adae43231334b4637aa88933dad99b1c33d3219fdfc94dfa4"}, + {file = "voyageai-0.2.4.tar.gz", hash = "sha256:b9911d8629e8a4e363291c133482fead49a3536afdf1e735f3ab3aaccd8d250d"}, +] + +[package.dependencies] +aiohttp = ">=3.5,<4.0" +aiolimiter = ">=1.1.0,<2.0.0" +numpy = ">=1.11" +requests = ">=2.20,<3.0" +tenacity = ">=8.0.1" + [[package]] name = "watchfiles" version = "1.1.1" @@ -3404,4 +3451,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "b558e94d5d8bdcc4273f47c52c8bfa6f4e003df0cf754f56340b8b98283d4a8d" +content-hash = "c7451186323054c141e54ee61d9e6364a2c7a4836a1de402f524a350b750182d" diff --git a/api/pyproject.toml b/api/pyproject.toml index 09760f8b1..30ebb7998 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -28,6 +28,7 @@ boto3 = ">=1.34.0" websockets = ">=11.0.3" azure-identity = ">=1.12.0" azure-core = ">=1.24.0" +voyageai = ">=0.2.0" [build-system] diff --git a/api/rag.py b/api/rag.py index 6908a9315..63d29a180 100644 --- a/api/rag.py +++ b/api/rag.py @@ -190,20 +190,24 @@ def __init__(self, provider="google", model=None, use_s3: bool = False): # noqa self.memory = Memory() self.embedder = get_embedder(embedder_type=self.embedder_type) - self_weakref = weakref.ref(self) - # Patch: ensure query embedding is always single string for Ollama - def single_string_embedder(query): - # Accepts either a string or a list, always returns embedding for a single string - if isinstance(query, list): - if len(query) != 1: - raise ValueError("Ollama embedder only supports a single string") - query = query[0] - instance = self_weakref() - assert instance is not None, "RAG instance is no longer available, but the query embedder was called." - return instance.embedder(input=query) - - # Use single string embedder for Ollama, regular embedder for others - self.query_embedder = single_string_embedder if self.is_ollama_embedder else self.embedder + # Voyage requires input_type="query" for retrieval — initialize a dedicated query + # embedder once at startup rather than allocating a new client per call. + if self.embedder_type == 'voyage': + self.query_embedder = get_embedder(embedder_type='voyage', input_type='query') + elif self.is_ollama_embedder: + # Ollama needs single-string coercion (doesn't accept list inputs) + self_weakref = weakref.ref(self) + def single_string_embedder(query): + if isinstance(query, list): + if len(query) != 1: + raise ValueError("Ollama embedder only supports a single string") + query = query[0] + instance = self_weakref() + assert instance is not None, "RAG instance is no longer available" + return instance.embedder(input=query) + self.query_embedder = single_string_embedder + else: + self.query_embedder = self.embedder self.initialize_db_manager() @@ -381,7 +385,7 @@ def prepare_retriever(self, repo_url_or_path: str, type: str = "github", access_ try: # Use the appropriate embedder for retrieval - retrieve_embedder = self.query_embedder if self.is_ollama_embedder else self.embedder + retrieve_embedder = self.query_embedder if self.embedder_type in ('ollama', 'voyage') else self.embedder self.retriever = FAISSRetriever( **configs["retriever"], embedder=retrieve_embedder, diff --git a/api/tools/embedder.py b/api/tools/embedder.py index 050d63547..c6b27c436 100644 --- a/api/tools/embedder.py +++ b/api/tools/embedder.py @@ -3,13 +3,14 @@ from api.config import configs, get_embedder_type -def get_embedder(is_local_ollama: bool = False, use_google_embedder: bool = False, embedder_type: str = None) -> adal.Embedder: +def get_embedder(is_local_ollama: bool = False, use_google_embedder: bool = False, embedder_type: str = None, input_type: str = None) -> adal.Embedder: """Get embedder based on configuration or parameters. Args: is_local_ollama: Legacy parameter for Ollama embedder use_google_embedder: Legacy parameter for Google embedder - embedder_type: Direct specification of embedder type ('ollama', 'google', 'bedrock', 'openai') + embedder_type: Direct specification of embedder type ('ollama', 'google', 'bedrock', 'openai', 'voyage') + input_type: Optional input_type for Voyage/other embedders ('document' or 'query') Returns: adal.Embedder: Configured embedder instance @@ -22,6 +23,8 @@ def get_embedder(is_local_ollama: bool = False, use_google_embedder: bool = Fals embedder_config = configs["embedder_google"] elif embedder_type == 'bedrock': embedder_config = configs["embedder_bedrock"] + elif embedder_type == 'voyage': + embedder_config = configs["embedder_voyage"] else: # default to openai embedder_config = configs["embedder"] elif is_local_ollama: @@ -37,6 +40,8 @@ def get_embedder(is_local_ollama: bool = False, use_google_embedder: bool = Fals embedder_config = configs["embedder_ollama"] elif current_type == 'google': embedder_config = configs["embedder_google"] + elif current_type == 'voyage': + embedder_config = configs["embedder_voyage"] else: embedder_config = configs["embedder"] @@ -50,6 +55,12 @@ def get_embedder(is_local_ollama: bool = False, use_google_embedder: bool = Fals # Create embedder with basic parameters embedder_kwargs = {"model_client": model_client, "model_kwargs": embedder_config["model_kwargs"]} + # Override input_type if provided (critical for Voyage AI retrieval vs indexing) + if input_type and "model_kwargs" in embedder_kwargs: + # Create a copy to avoid modifying the global config + embedder_kwargs["model_kwargs"] = embedder_kwargs["model_kwargs"].copy() + embedder_kwargs["model_kwargs"]["input_type"] = input_type + embedder = adal.Embedder(**embedder_kwargs) # Set batch_size as an attribute if available (not a constructor parameter) diff --git a/api/voyage_client.py b/api/voyage_client.py new file mode 100644 index 000000000..bc6fe71fa --- /dev/null +++ b/api/voyage_client.py @@ -0,0 +1,227 @@ +"""Voyage AI Embeddings ModelClient integration.""" + +import os +import logging +import backoff +from typing import Dict, Any, Optional, List, Sequence + +from adalflow.core.model_client import ModelClient +from adalflow.core.types import ModelType, EmbedderOutput + +try: + import voyageai +except ImportError: + raise ImportError("voyageai is required. Install it with 'pip install voyageai'") + +log = logging.getLogger(__name__) + + +class VoyageEmbedderClient(ModelClient): + __doc__ = r"""A component wrapper for Voyage AI Embeddings API client. + + This client provides access to Voyage AI's specialized embedding models. + It supports text embeddings optimized for code retrieval and general semantic search. + + Args: + api_key (Optional[str]): Voyage AI API key. Defaults to None. + If not provided, will use the VOYAGE_API_KEY environment variable. + env_api_key_name (str): Environment variable name for the API key. + Defaults to "VOYAGE_API_KEY". + + Example: + ```python + from api.voyage_client import VoyageEmbedderClient + import adalflow as adal + + client = VoyageEmbedderClient() + embedder = adal.Embedder( + model_client=client, + model_kwargs={ + "model": "voyage-code-2", + "input_type": "document" # or "query" + } + ) + ``` + """ + + def __init__( + self, + api_key: Optional[str] = None, + env_api_key_name: str = "VOYAGE_API_KEY", + ): + """Initialize Voyage AI Embeddings client. + + Args: + api_key: Voyage AI API key. If not provided, uses environment variable. + env_api_key_name: Name of environment variable containing API key. + """ + super().__init__() + self._api_key = api_key + self._env_api_key_name = env_api_key_name + self._client: Optional[Any] = None + self._voyage_async_client: Optional[Any] = None + + def _initialize_client(self): + """Initialize (or re-initialize) the Voyage AI client with API key.""" + api_key = self._api_key or os.getenv(self._env_api_key_name) + if not api_key: + raise ValueError( + f"Environment variable {self._env_api_key_name} must be set" + ) + self._client = voyageai.Client(api_key=api_key) + + @property + def client(self): + """Lazy client accessor — reconstructs after unpickling.""" + if self._client is None: + self._initialize_client() + return self._client + + def __getstate__(self): + """Exclude unpicklable voyageai clients from pickle state.""" + state = self.__dict__.copy() + state['_client'] = None + state['_voyage_async_client'] = None + return state + + def __setstate__(self, state): + """Restore state; client will be lazily re-initialized on next use.""" + self.__dict__.update(state) + + def to_dict(self, exclude: Optional[List[str]] = None) -> dict: + """Serialize to dict, excluding the voyageai.Client which is not serializable. + + voyageai.Client internally uses tenacity retry objects (retry_if_exception_type) + that contain lambdas and cannot be pickled or serialized. The client is + reconstructed lazily from _api_key / _env_api_key_name on next use. + """ + exclude = list(exclude or []) + ['_client', '_voyage_async_client'] + return super().to_dict(exclude=exclude) + + def parse_embedding_response(self, response) -> EmbedderOutput: + """Parse Voyage AI embedding response to EmbedderOutput format. + + Args: + response: Voyage AI embedding response object + + Returns: + EmbedderOutput with parsed embeddings + """ + try: + from adalflow.core.types import Embedding + + # The response object has an 'embeddings' attribute which is a list of lists + embedding_data = [] + + if hasattr(response, 'embeddings') and response.embeddings: + embedding_data = [ + Embedding(embedding=emb, index=i) + for i, emb in enumerate(response.embeddings) + ] + + if embedding_data: + first_dim = len(embedding_data[0].embedding) + log.info("Parsed %s embedding(s) (dim=%s)", len(embedding_data), first_dim) + else: + log.warning("Empty or invalid embedding data in response") + + return EmbedderOutput( + data=embedding_data, + error=None, + raw_response=response + ) + except Exception as e: + log.error(f"Error parsing Voyage AI embedding response: {e}") + return EmbedderOutput( + data=[], + error=str(e), + raw_response=response + ) + + def convert_inputs_to_api_kwargs( + self, + input: Optional[Any] = None, + model_kwargs: Dict = {}, + model_type: ModelType = ModelType.UNDEFINED, + ) -> Dict: + """Convert inputs to Voyage AI API format. + + Args: + input: Text input(s) to embed + model_kwargs: Model parameters including model name and input_type + model_type: Should be ModelType.EMBEDDER for this client + + Returns: + Dict: API kwargs for Voyage AI embedding call + """ + if model_type != ModelType.EMBEDDER: + raise ValueError(f"VoyageEmbedderClient only supports EMBEDDER model type, got {model_type}") + + # Ensure input is a list + if isinstance(input, str): + texts = [input] + elif isinstance(input, Sequence): + texts = list(input) + else: + raise TypeError("input must be a string or sequence of strings") + + final_model_kwargs = model_kwargs.copy() + final_model_kwargs["texts"] = texts + + # Set default model if not provided + if "model" not in final_model_kwargs: + final_model_kwargs["model"] = "voyage-code-3" + + # Ensure input_type is set (default to document; callers set "query" for retrieval) + if "input_type" not in final_model_kwargs: + final_model_kwargs["input_type"] = "document" + + return final_model_kwargs + + @backoff.on_exception( + backoff.expo, + Exception, + max_tries=3, + giveup=lambda e: isinstance(e, (ValueError, TypeError)), + ) + def call(self, api_kwargs: Dict = {}, model_type: ModelType = ModelType.UNDEFINED): + """Call Voyage AI embedding API. + + Args: + api_kwargs: API parameters + model_type: Should be ModelType.EMBEDDER + + Returns: + Voyage AI embedding response + """ + if model_type != ModelType.EMBEDDER: + raise ValueError("VoyageEmbedderClient only supports EMBEDDER model type") + + safe_log_kwargs = {k: v for k, v in api_kwargs.items() if k != "texts"} + if "texts" in api_kwargs: + safe_log_kwargs["texts_count"] = len(api_kwargs["texts"]) + + log.info("Voyage AI Embeddings call kwargs (sanitized): %s", safe_log_kwargs) + return self.client.embed(**api_kwargs) + + @property + def voyage_async_client(self): + """Lazy async client accessor — reconstructs after unpickling.""" + if self._voyage_async_client is None: + api_key = self._api_key or os.getenv(self._env_api_key_name) + if not api_key: + raise ValueError(f"Environment variable {self._env_api_key_name} must be set") + self._voyage_async_client = voyageai.AsyncClient(api_key=api_key) + return self._voyage_async_client + + async def acall(self, api_kwargs: Dict = {}, model_type: ModelType = ModelType.UNDEFINED): + """Async call to Voyage AI embedding API via voyageai.AsyncClient.""" + if model_type != ModelType.EMBEDDER: + raise ValueError("VoyageEmbedderClient only supports EMBEDDER model type") + + safe_log_kwargs = {k: v for k, v in api_kwargs.items() if k != "texts"} + if "texts" in api_kwargs: + safe_log_kwargs["texts_count"] = len(api_kwargs["texts"]) + + log.info("Voyage AI Embeddings async call kwargs (sanitized): %s", safe_log_kwargs) + return await self.voyage_async_client.embed(**api_kwargs) diff --git a/docker-compose.yml b/docker-compose.yml index 15f9cdb62..ef112928e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: - LOG_LEVEL=${LOG_LEVEL:-INFO} - LOG_FILE_PATH=${LOG_FILE_PATH:-api/logs/application.log} volumes: - - ~/.adalflow:/root/.adalflow # Persist repository and embedding data + - ${HOME}/.adalflow:/root/.adalflow # Persist repository and embedding data - ./api/logs:/app/api/logs # Persist log files across container restarts # Resource limits for docker-compose up (not Swarm mode) mem_limit: 6g diff --git a/tests/unit/test_all_embedders.py b/tests/unit/test_all_embedders.py index 2aa0e47ce..4f81b0d96 100644 --- a/tests/unit/test_all_embedders.py +++ b/tests/unit/test_all_embedders.py @@ -93,38 +93,44 @@ def test_config_loading(self): assert 'embedder_google' in configs, "Google embedder config missing" assert 'embedder_ollama' in configs, "Ollama embedder config missing" assert 'embedder_bedrock' in configs, "Bedrock embedder config missing" + assert 'embedder_voyage' in configs, "Voyage embedder config missing" # Check client classes are available assert 'OpenAIClient' in CLIENT_CLASSES, "OpenAIClient missing from CLIENT_CLASSES" assert 'GoogleEmbedderClient' in CLIENT_CLASSES, "GoogleEmbedderClient missing from CLIENT_CLASSES" assert 'OllamaClient' in CLIENT_CLASSES, "OllamaClient missing from CLIENT_CLASSES" assert 'BedrockClient' in CLIENT_CLASSES, "BedrockClient missing from CLIENT_CLASSES" + assert 'VoyageEmbedderClient' in CLIENT_CLASSES, "VoyageEmbedderClient missing from CLIENT_CLASSES" def test_embedder_type_detection(self): """Test embedder type detection functions.""" - from api.config import get_embedder_type, is_ollama_embedder, is_google_embedder, is_bedrock_embedder + from api.config import get_embedder_type, is_ollama_embedder, is_google_embedder, is_bedrock_embedder, is_voyage_embedder # Default type should be detected current_type = get_embedder_type() - assert current_type in ['openai', 'google', 'ollama', 'bedrock'], f"Invalid embedder type: {current_type}" + assert current_type in ['openai', 'google', 'ollama', 'bedrock', 'voyage'], f"Invalid embedder type: {current_type}" # Boolean functions should work is_ollama = is_ollama_embedder() is_google = is_google_embedder() is_bedrock = is_bedrock_embedder() + is_voyage = is_voyage_embedder() assert isinstance(is_ollama, bool), "is_ollama_embedder should return boolean" assert isinstance(is_google, bool), "is_google_embedder should return boolean" assert isinstance(is_bedrock, bool), "is_bedrock_embedder should return boolean" + assert isinstance(is_voyage, bool), "is_voyage_embedder should return boolean" # Only one should be true at a time (unless using openai default) if current_type == 'bedrock': - assert is_bedrock and not is_ollama and not is_google + assert is_bedrock and not is_ollama and not is_google and not is_voyage elif current_type == 'ollama': - assert is_ollama and not is_google and not is_bedrock + assert is_ollama and not is_google and not is_bedrock and not is_voyage elif current_type == 'google': - assert is_google and not is_ollama and not is_bedrock + assert is_google and not is_ollama and not is_bedrock and not is_voyage + elif current_type == 'voyage': + assert is_voyage and not is_ollama and not is_google and not is_bedrock else: # openai - assert not is_ollama and not is_google and not is_bedrock + assert not is_ollama and not is_google and not is_bedrock and not is_voyage def test_get_embedder_config(self, embedder_type=None): """Test getting embedder config for each type."""