From 0e11abb15dc7ec7ed8cd282ff2b77190efba4598 Mon Sep 17 00:00:00 2001 From: fzowl Date: Tue, 8 Sep 2026 18:45:36 +0200 Subject: [PATCH 1/9] feat(voyage): rebrand to VoyageAI by MongoDB, refresh model list, support both contextual input shapes --- README.md | 2 +- .../embedding/transformation_contextual.py | 18 ++++- ...odel_prices_and_context_window_backup.json | 20 ++++++ .../provider_endpoints_support_backup.json | 2 +- .../provider_create_fields.json | 2 +- model_prices_and_context_window.json | 20 ++++++ provider_endpoints_support.json | 2 +- tests/llm_translation/test_voyage_ai.py | 66 +++++++++++++++++++ .../src/components/provider_info_helpers.tsx | 2 +- 9 files changed, 128 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 92757fcbbc1..2879802981d 100644 --- a/README.md +++ b/README.md @@ -370,7 +370,7 @@ curl -X POST 'http://0.0.0.0:4000/v1/chat/completions' \ | [Vercel AI Gateway (`vercel_ai_gateway`)](https://docs.litellm.ai/docs/providers/vercel_ai_gateway) | ✅ | ✅ | ✅ | | | | | | | | | [VLLM (`vllm`)](https://docs.litellm.ai/docs/providers/vllm) | ✅ | ✅ | ✅ | | | | | | | | | [Volcengine (`volcengine`)](https://docs.litellm.ai/docs/providers/volcano) | ✅ | ✅ | ✅ | | | | | | | | -| [Voyage AI (`voyage`)](https://docs.litellm.ai/docs/providers/voyage) | | | | ✅ | | | | | | | +| [VoyageAI by MongoDB (`voyage`)](https://docs.litellm.ai/docs/providers/voyage) | | | | ✅ | | | | | | | | [WandB Inference (`wandb`)](https://docs.litellm.ai/docs/providers/wandb_inference) | ✅ | ✅ | ✅ | | | | | | | | | [Watsonx Text (`watsonx_text`)](https://docs.litellm.ai/docs/providers/watsonx) | ✅ | ✅ | ✅ | | | | | | | | | [xAI (`xai`)](https://docs.litellm.ai/docs/providers/xai) | ✅ | ✅ | ✅ | | | | | | | | diff --git a/litellm/llms/voyage/embedding/transformation_contextual.py b/litellm/llms/voyage/embedding/transformation_contextual.py index ec77ce91d33..260806fc164 100644 --- a/litellm/llms/voyage/embedding/transformation_contextual.py +++ b/litellm/llms/voyage/embedding/transformation_contextual.py @@ -105,11 +105,27 @@ class VoyageContextualEmbeddingConfig(BaseEmbeddingConfig): headers: dict, ) -> dict: return { - "inputs": input, + "inputs": self._normalize_contextual_inputs(input), "model": model, **optional_params, } + @staticmethod + def _normalize_contextual_inputs( + input: AllEmbeddingInputValues | list[list[str]], + ) -> AllEmbeddingInputValues | list[list[str]]: + """ + Voyage's contextualized embeddings API accepts ``inputs`` as either a + flat ``list[str]`` (one document's chunks) or a nested ``list[list[str]]`` + (multiple documents). Both are sent through unchanged; a bare ``str`` is + wrapped into a single-element list so the payload always matches the spec. + + Reference: https://docs.voyageai.com/docs/contextualized-chunk-embeddings + """ + if isinstance(input, str): + return [input] + return input + def transform_embedding_response( self, model: str, diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 4273ec54472..8177b6ce142 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -56413,6 +56413,26 @@ "output_vector_size": 1024, "source": "https://docs.voyageai.com/docs/pricing" }, + "voyage/voyage-4-nano": { + "input_cost_per_token": 0.0, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.voyageai.com/docs/embeddings" + }, + "voyage/voyage-multilingual-2": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.voyageai.com/docs/pricing" + }, "voyage/voyage-context-4": { "input_cost_per_token": 1.2e-07, "litellm_provider": "voyage", diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index dbeaccdda2d..1f512ab927e 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -2283,7 +2283,7 @@ } }, "voyage": { - "display_name": "Voyage AI (`voyage`)", + "display_name": "VoyageAI by MongoDB (`voyage`)", "url": "https://docs.litellm.ai/docs/providers/voyage", "endpoints": { "chat_completions": false, diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index 66f8c2ea36f..ae24eb78bfe 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -3228,7 +3228,7 @@ }, { "provider": "Voyage", - "provider_display_name": "Voyage AI", + "provider_display_name": "VoyageAI by MongoDB", "litellm_provider": "voyage", "credential_fields": [ { diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 4273ec54472..8177b6ce142 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -56413,6 +56413,26 @@ "output_vector_size": 1024, "source": "https://docs.voyageai.com/docs/pricing" }, + "voyage/voyage-4-nano": { + "input_cost_per_token": 0.0, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.voyageai.com/docs/embeddings" + }, + "voyage/voyage-multilingual-2": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.voyageai.com/docs/pricing" + }, "voyage/voyage-context-4": { "input_cost_per_token": 1.2e-07, "litellm_provider": "voyage", diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index c71f4a82a4a..3af67954bed 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -2594,7 +2594,7 @@ } }, "voyage": { - "display_name": "Voyage AI (`voyage`)", + "display_name": "VoyageAI by MongoDB (`voyage`)", "url": "https://docs.litellm.ai/docs/providers/voyage", "endpoints": { "chat_completions": false, diff --git a/tests/llm_translation/test_voyage_ai.py b/tests/llm_translation/test_voyage_ai.py index 208e01110da..33705bf7b34 100644 --- a/tests/llm_translation/test_voyage_ai.py +++ b/tests/llm_translation/test_voyage_ai.py @@ -195,6 +195,51 @@ class TestVoyageContextualEmbeddings: assert transformed["model"] == "voyage-context-3" assert transformed["encoding_format"] == "float" + def test_contextual_embedding_flat_list_input(self): + """A flat list[str] is passed through unchanged as inputs (spec allows list[str])""" + from litellm.llms.voyage.embedding.transformation_contextual import ( + VoyageContextualEmbeddingConfig, + ) + + config = VoyageContextualEmbeddingConfig() + flat_input = ["chunk one", "chunk two"] + + transformed = config.transform_embedding_request( + "voyage-context-4", flat_input, {}, {} + ) + + assert transformed["inputs"] == flat_input + assert transformed["model"] == "voyage-context-4" + + def test_contextual_embedding_nested_list_input(self): + """A nested list[list[str]] is passed through unchanged as inputs""" + from litellm.llms.voyage.embedding.transformation_contextual import ( + VoyageContextualEmbeddingConfig, + ) + + config = VoyageContextualEmbeddingConfig() + nested_input = [["doc a chunk 1", "doc a chunk 2"], ["doc b chunk 1"]] + + transformed = config.transform_embedding_request( + "voyage-context-4", nested_input, {}, {} + ) + + assert transformed["inputs"] == nested_input + + def test_contextual_embedding_str_input_wrapped(self): + """A bare str is wrapped into a single-element list so inputs is always a list""" + from litellm.llms.voyage.embedding.transformation_contextual import ( + VoyageContextualEmbeddingConfig, + ) + + config = VoyageContextualEmbeddingConfig() + + transformed = config.transform_embedding_request( + "voyage-context-4", "just one chunk", {}, {} + ) + + assert transformed["inputs"] == ["just one chunk"] + def test_contextual_embedding_response_transformation(self): """Test response transformation for contextual embeddings""" from litellm.llms.voyage.embedding.transformation_contextual import ( @@ -428,3 +473,24 @@ class TestVoyageContextualEmbeddings: except Exception as e: pytest.fail(f"Error occurred: {e}") + + +def test_voyage_current_models_registered(): + """The models currently listed on docs.voyageai.com resolve with voyage pricing/context""" + from litellm import get_model_info + + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + expected = { + "voyage/voyage-4-nano": {"max_input_tokens": 32000, "input_cost_per_token": 0.0}, + "voyage/voyage-multilingual-2": {"max_input_tokens": 32000, "input_cost_per_token": 1.2e-07}, + "voyage/voyage-context-4": {"max_input_tokens": 120000, "input_cost_per_token": 1.2e-07}, + } + + for model, fields in expected.items(): + info = get_model_info(model) + assert info["litellm_provider"] == "voyage", f"{model} wrong provider" + assert info["mode"] == "embedding", f"{model} wrong mode" + assert info["max_input_tokens"] == fields["max_input_tokens"], f"{model} wrong context" + assert info["input_cost_per_token"] == fields["input_cost_per_token"], f"{model} wrong price" diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index d01a6a34cbe..3dbc3d7df26 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -171,7 +171,7 @@ export enum Providers { VERTEX_AI_BETA = "Vertex Ai Beta", VLLM = "Local vLLM", VolcEngine = "VolcEngine", - Voyage = "Voyage AI", + Voyage = "VoyageAI by MongoDB", WANDB = "Wandb", WATSONX = "Watsonx", WATSONX_TEXT = "Watsonx Text", From 236d7698275ea58920110ec10e44faca3c33876c Mon Sep 17 00:00:00 2001 From: fzowl Date: Tue, 8 Sep 2026 18:54:56 +0200 Subject: [PATCH 2/9] fix(voyage): send auto-chunking params for flat contextual inputs A flat list[str] or bare str for the contextualized embeddings endpoint is only valid as documents with enable_auto_chunking=True and input_type=document, or as queries with input_type=query. Default those params for non-query flat inputs so the request matches the live API contract, while letting caller-set values win. Nested list[list[str]] still passes through unchanged. Tests now assert the auto-chunking params instead of only echoing inputs. --- .../embedding/transformation_contextual.py | 34 ++++++++---- tests/llm_translation/test_voyage_ai.py | 55 +++++++++++++++++-- 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/litellm/llms/voyage/embedding/transformation_contextual.py b/litellm/llms/voyage/embedding/transformation_contextual.py index 260806fc164..bcfb55b278c 100644 --- a/litellm/llms/voyage/embedding/transformation_contextual.py +++ b/litellm/llms/voyage/embedding/transformation_contextual.py @@ -104,27 +104,41 @@ class VoyageContextualEmbeddingConfig(BaseEmbeddingConfig): optional_params: dict, headers: dict, ) -> dict: + inputs, contextual_params = self._prepare_contextual_inputs(input, optional_params) return { - "inputs": self._normalize_contextual_inputs(input), + "inputs": inputs, "model": model, **optional_params, + **contextual_params, } @staticmethod - def _normalize_contextual_inputs( + def _prepare_contextual_inputs( input: AllEmbeddingInputValues | list[list[str]], - ) -> AllEmbeddingInputValues | list[list[str]]: + optional_params: dict, + ) -> tuple[AllEmbeddingInputValues | list[list[str]], dict[str, str | bool]]: """ - Voyage's contextualized embeddings API accepts ``inputs`` as either a - flat ``list[str]`` (one document's chunks) or a nested ``list[list[str]]`` - (multiple documents). Both are sent through unchanged; a bare ``str`` is - wrapped into a single-element list so the payload always matches the spec. + Shape ``inputs`` and the auto-chunking params to match Voyage's + contextualized embeddings contract. + + - ``list[list[str]]`` (pre-chunked documents) is always valid and passes through. + - A flat ``list[str]`` or bare ``str`` is only valid as documents when + ``enable_auto_chunking=True`` with ``input_type="document"``, or as + queries with ``input_type="query"``. So a non-query flat input is sent + with those two params defaulted (caller-set values win). Reference: https://docs.voyageai.com/docs/contextualized-chunk-embeddings """ - if isinstance(input, str): - return [input] - return input + if isinstance(input, list) and len(input) > 0 and isinstance(input[0], list): + return input, {} + flat: Final = [input] if isinstance(input, str) else input + if optional_params.get("input_type") == "query": + return flat, {} + contextual_params: Final = { + **({"input_type": "document"} if "input_type" not in optional_params else {}), + **({"enable_auto_chunking": True} if "enable_auto_chunking" not in optional_params else {}), + } + return flat, contextual_params def transform_embedding_response( self, diff --git a/tests/llm_translation/test_voyage_ai.py b/tests/llm_translation/test_voyage_ai.py index 33705bf7b34..96fb5974ee4 100644 --- a/tests/llm_translation/test_voyage_ai.py +++ b/tests/llm_translation/test_voyage_ai.py @@ -195,8 +195,8 @@ class TestVoyageContextualEmbeddings: assert transformed["model"] == "voyage-context-3" assert transformed["encoding_format"] == "float" - def test_contextual_embedding_flat_list_input(self): - """A flat list[str] is passed through unchanged as inputs (spec allows list[str])""" + def test_contextual_embedding_flat_list_defaults_to_document_auto_chunk(self): + """A flat list[str] with no input_type is documents, so it needs auto-chunking + input_type=document""" from litellm.llms.voyage.embedding.transformation_contextual import ( VoyageContextualEmbeddingConfig, ) @@ -210,9 +210,28 @@ class TestVoyageContextualEmbeddings: assert transformed["inputs"] == flat_input assert transformed["model"] == "voyage-context-4" + assert transformed["input_type"] == "document" + assert transformed["enable_auto_chunking"] is True - def test_contextual_embedding_nested_list_input(self): - """A nested list[list[str]] is passed through unchanged as inputs""" + def test_contextual_embedding_flat_list_query_stays_flat_without_auto_chunk(self): + """A flat list[str] of queries is valid as-is, so no auto-chunking must be forced on""" + from litellm.llms.voyage.embedding.transformation_contextual import ( + VoyageContextualEmbeddingConfig, + ) + + config = VoyageContextualEmbeddingConfig() + flat_input = ["what is voyage", "who owns voyage"] + + transformed = config.transform_embedding_request( + "voyage-context-4", flat_input, {"input_type": "query"}, {} + ) + + assert transformed["inputs"] == flat_input + assert transformed["input_type"] == "query" + assert "enable_auto_chunking" not in transformed + + def test_contextual_embedding_nested_list_input_passes_through(self): + """A nested list[list[str]] is pre-chunked documents, valid unchanged with no extra params""" from litellm.llms.voyage.embedding.transformation_contextual import ( VoyageContextualEmbeddingConfig, ) @@ -225,9 +244,11 @@ class TestVoyageContextualEmbeddings: ) assert transformed["inputs"] == nested_input + assert "enable_auto_chunking" not in transformed + assert "input_type" not in transformed - def test_contextual_embedding_str_input_wrapped(self): - """A bare str is wrapped into a single-element list so inputs is always a list""" + def test_contextual_embedding_str_input_wrapped_with_auto_chunk(self): + """A bare str is wrapped to a one-element list and, as documents, gets auto-chunking + input_type=document""" from litellm.llms.voyage.embedding.transformation_contextual import ( VoyageContextualEmbeddingConfig, ) @@ -239,6 +260,28 @@ class TestVoyageContextualEmbeddings: ) assert transformed["inputs"] == ["just one chunk"] + assert transformed["input_type"] == "document" + assert transformed["enable_auto_chunking"] is True + + def test_contextual_embedding_caller_params_win(self): + """Caller-set input_type=document with explicit auto-chunking off must be respected, not overridden""" + from litellm.llms.voyage.embedding.transformation_contextual import ( + VoyageContextualEmbeddingConfig, + ) + + config = VoyageContextualEmbeddingConfig() + flat_input = ["chunk a", "chunk b"] + + transformed = config.transform_embedding_request( + "voyage-context-4", + flat_input, + {"input_type": "document", "enable_auto_chunking": False}, + {}, + ) + + assert transformed["inputs"] == flat_input + assert transformed["input_type"] == "document" + assert transformed["enable_auto_chunking"] is False def test_contextual_embedding_response_transformation(self): """Test response transformation for contextual embeddings""" From 9c89ab96aaba9dc75d93e5db3067a80132243572 Mon Sep 17 00:00:00 2001 From: fzowl Date: Fri, 18 Sep 2026 14:49:55 +0200 Subject: [PATCH 3/9] feat(voyage): route MongoDB-issued keys to ai.mongodb.com Mirror voyageai.util.get_default_base_url from the official SDK: a key with the `al-` prefix is issued by MongoDB and is only valid on ai.mongodb.com, every other key on api.voyageai.com. The choice now lives in one shared helper that the embedding, contextual, multimodal, and rerank configs all use for both the default base URL and the Authorization header, so the host and the key always agree. Also drops the voyage-4-nano and voyage-multilingual-2 model map entries: voyage-4-nano is not served on the Voyage API and voyage-multilingual-2 is an older model, so neither belongs in this change. --- litellm/llms/voyage/common_utils.py | 35 ++++++ .../llms/voyage/embedding/transformation.py | 12 +- .../embedding/transformation_contextual.py | 61 ++++----- .../embedding/transformation_multimodal.py | 15 +-- litellm/llms/voyage/rerank/transformation.py | 11 +- ...odel_prices_and_context_window_backup.json | 20 --- model_prices_and_context_window.json | 20 --- tests/llm_translation/test_voyage_ai.py | 25 +--- .../test_voyage_rerank_transformation.py | 7 +- .../llms/voyage/test_common_utils.py | 117 ++++++++++++++++++ .../test_voyage_multimodal_embedding.py | 11 +- 11 files changed, 196 insertions(+), 138 deletions(-) create mode 100644 litellm/llms/voyage/common_utils.py create mode 100644 tests/test_litellm/llms/voyage/test_common_utils.py diff --git a/litellm/llms/voyage/common_utils.py b/litellm/llms/voyage/common_utils.py new file mode 100644 index 00000000000..2f4f63a8a43 --- /dev/null +++ b/litellm/llms/voyage/common_utils.py @@ -0,0 +1,35 @@ +""" +Shared helpers for the Voyage (VoyageAI by MongoDB) provider. +""" + +from typing import Final + +from litellm.secret_managers.main import get_secret_str + +VOYAGE_API_BASE: Final = "https://api.voyageai.com/v1" +MONGODB_API_BASE: Final = "https://ai.mongodb.com/v1" +MONGODB_API_KEY_PREFIX: Final = "al-" + + +def get_voyage_api_key(api_key: str | None = None) -> str | None: + """Resolve the key a Voyage request will authenticate with, explicit value first.""" + return ( + api_key + or get_secret_str("VOYAGE_API_KEY") + or get_secret_str("VOYAGE_AI_API_KEY") + or get_secret_str("VOYAGE_AI_TOKEN") + ) + + +def get_default_base_url(api_key: str | None = None) -> str: + """ + Pick the host that issued the key: MongoDB-issued keys (``al-`` prefix) are only + valid on ai.mongodb.com, every other key on api.voyageai.com. + + Mirrors ``voyageai.util.get_default_base_url`` in the official SDK: + https://github.com/voyage-ai/voyageai-python/blob/main/voyageai/util.py + """ + resolved: Final = get_voyage_api_key(api_key) + if resolved is not None and resolved.startswith(MONGODB_API_KEY_PREFIX): + return MONGODB_API_BASE + return VOYAGE_API_BASE diff --git a/litellm/llms/voyage/embedding/transformation.py b/litellm/llms/voyage/embedding/transformation.py index 7d74b1e00c4..a9232ad27e2 100644 --- a/litellm/llms/voyage/embedding/transformation.py +++ b/litellm/llms/voyage/embedding/transformation.py @@ -5,7 +5,7 @@ import httpx from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.embedding.transformation import BaseEmbeddingConfig -from litellm.secret_managers.main import get_secret_str +from litellm.llms.voyage.common_utils import get_default_base_url, get_voyage_api_key from litellm.types.llms.openai import AllEmbeddingInputValues, AllMessageValues from litellm.types.utils import EmbeddingResponse, Usage @@ -49,7 +49,7 @@ class VoyageEmbeddingConfig(BaseEmbeddingConfig): if not api_base.endswith("/embeddings"): api_base = f"{api_base}/embeddings" return api_base - return "https://api.voyageai.com/v1/embeddings" + return f"{get_default_base_url(api_key)}/embeddings" def get_supported_openai_params(self, model: str) -> list: return [ @@ -85,14 +85,8 @@ class VoyageEmbeddingConfig(BaseEmbeddingConfig): api_key: str | None = None, api_base: str | None = None, ) -> dict: - if api_key is None: - api_key = ( - get_secret_str("VOYAGE_API_KEY") - or get_secret_str("VOYAGE_AI_API_KEY") - or get_secret_str("VOYAGE_AI_TOKEN") - ) return { - "Authorization": f"Bearer {api_key}", + "Authorization": f"Bearer {get_voyage_api_key(api_key)}", } def transform_embedding_request( diff --git a/litellm/llms/voyage/embedding/transformation_contextual.py b/litellm/llms/voyage/embedding/transformation_contextual.py index bcfb55b278c..b6f3757eae0 100644 --- a/litellm/llms/voyage/embedding/transformation_contextual.py +++ b/litellm/llms/voyage/embedding/transformation_contextual.py @@ -3,6 +3,8 @@ This module is used to transform the request and response for the Voyage context This would be used for all the contextualized embeddings models in Voyage. """ +from collections.abc import Mapping +from types import MappingProxyType from typing import Final import httpx @@ -10,10 +12,15 @@ import httpx from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.embedding.transformation import BaseEmbeddingConfig -from litellm.secret_managers.main import get_secret_str +from litellm.llms.voyage.common_utils import get_default_base_url, get_voyage_api_key from litellm.types.llms.openai import AllEmbeddingInputValues, AllMessageValues from litellm.types.utils import EmbeddingResponse, Usage +NO_CONTEXTUAL_DEFAULTS: Final[Mapping[str, str | bool]] = MappingProxyType({}) +AUTO_CHUNK_DEFAULTS: Final[Mapping[str, str | bool]] = MappingProxyType( + {"input_type": "document", "enable_auto_chunking": True} +) + class VoyageError(BaseLLMException): def __init__( @@ -54,7 +61,7 @@ class VoyageContextualEmbeddingConfig(BaseEmbeddingConfig): if not api_base.endswith("/contextualizedembeddings"): api_base = f"{api_base}/contextualizedembeddings" return api_base - return "https://api.voyageai.com/v1/contextualizedembeddings" + return f"{get_default_base_url(api_key)}/contextualizedembeddings" def get_supported_openai_params(self, model: str) -> list: return ["encoding_format", "dimensions"] @@ -87,14 +94,8 @@ class VoyageContextualEmbeddingConfig(BaseEmbeddingConfig): api_key: str | None = None, api_base: str | None = None, ) -> dict: - if api_key is None: - api_key = ( - get_secret_str("VOYAGE_API_KEY") - or get_secret_str("VOYAGE_AI_API_KEY") - or get_secret_str("VOYAGE_AI_TOKEN") - ) return { - "Authorization": f"Bearer {api_key}", + "Authorization": f"Bearer {get_voyage_api_key(api_key)}", } def transform_embedding_request( @@ -104,41 +105,23 @@ class VoyageContextualEmbeddingConfig(BaseEmbeddingConfig): optional_params: dict, headers: dict, ) -> dict: - inputs, contextual_params = self._prepare_contextual_inputs(input, optional_params) - return { - "inputs": inputs, - "model": model, - **optional_params, - **contextual_params, - } - - @staticmethod - def _prepare_contextual_inputs( - input: AllEmbeddingInputValues | list[list[str]], - optional_params: dict, - ) -> tuple[AllEmbeddingInputValues | list[list[str]], dict[str, str | bool]]: """ - Shape ``inputs`` and the auto-chunking params to match Voyage's - contextualized embeddings contract. - - - ``list[list[str]]`` (pre-chunked documents) is always valid and passes through. - - A flat ``list[str]`` or bare ``str`` is only valid as documents when - ``enable_auto_chunking=True`` with ``input_type="document"``, or as - queries with ``input_type="query"``. So a non-query flat input is sent - with those two params defaulted (caller-set values win). + Shape ``inputs`` and the auto-chunking params to match Voyage's contextualized + embeddings contract: ``list[list[str]]`` (pre-chunked documents) is always + valid, while a flat ``list[str]`` or bare ``str`` is valid only as queries + (``input_type="query"``) or as documents with ``enable_auto_chunking=True`` + and ``input_type="document"``. Caller-set params win. Reference: https://docs.voyageai.com/docs/contextualized-chunk-embeddings """ - if isinstance(input, list) and len(input) > 0 and isinstance(input[0], list): - return input, {} - flat: Final = [input] if isinstance(input, str) else input - if optional_params.get("input_type") == "query": - return flat, {} - contextual_params: Final = { - **({"input_type": "document"} if "input_type" not in optional_params else {}), - **({"enable_auto_chunking": True} if "enable_auto_chunking" not in optional_params else {}), + is_prechunked: Final = isinstance(input, list) and len(input) > 0 and isinstance(input[0], list) + is_query: Final = optional_params.get("input_type") == "query" + return { + "inputs": (input,) if isinstance(input, str) else input, + "model": model, + **(NO_CONTEXTUAL_DEFAULTS if is_prechunked or is_query else AUTO_CHUNK_DEFAULTS), + **optional_params, } - return flat, contextual_params def transform_embedding_response( self, diff --git a/litellm/llms/voyage/embedding/transformation_multimodal.py b/litellm/llms/voyage/embedding/transformation_multimodal.py index 814d5ab7eb0..035765b6691 100644 --- a/litellm/llms/voyage/embedding/transformation_multimodal.py +++ b/litellm/llms/voyage/embedding/transformation_multimodal.py @@ -13,7 +13,7 @@ import httpx from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.embedding.transformation import BaseEmbeddingConfig -from litellm.secret_managers.main import get_secret_str +from litellm.llms.voyage.common_utils import get_default_base_url, get_voyage_api_key from litellm.types.llms.openai import AllEmbeddingInputValues, AllMessageValues from litellm.types.utils import EmbeddingResponse, Usage @@ -58,7 +58,7 @@ class VoyageMultimodalEmbeddingConfig(BaseEmbeddingConfig): if not api_base.endswith("/multimodalembeddings"): api_base = f"{api_base}/multimodalembeddings" return api_base - return "https://api.voyageai.com/v1/multimodalembeddings" + return f"{get_default_base_url(api_key)}/multimodalembeddings" def get_supported_openai_params(self, model: str) -> list: return ["dimensions"] @@ -84,19 +84,14 @@ class VoyageMultimodalEmbeddingConfig(BaseEmbeddingConfig): api_key: str | None = None, api_base: str | None = None, ) -> dict: - if api_key is None: - api_key = ( - get_secret_str("VOYAGE_API_KEY") - or get_secret_str("VOYAGE_AI_API_KEY") - or get_secret_str("VOYAGE_AI_TOKEN") - ) - if not api_key: + resolved_api_key: Final = get_voyage_api_key(api_key) + if not resolved_api_key: raise ValueError( "Voyage API key is required for multimodal embeddings. " "Set VOYAGE_API_KEY / VOYAGE_AI_API_KEY / VOYAGE_AI_TOKEN " "or pass `api_key` explicitly." ) - return {"Authorization": f"Bearer {api_key}"} + return {"Authorization": f"Bearer {resolved_api_key}"} def _normalize_content_item(self, item: dict[str, object]) -> dict[str, object]: item_type: Final = item.get("type") diff --git a/litellm/llms/voyage/rerank/transformation.py b/litellm/llms/voyage/rerank/transformation.py index fea8452d934..ed453dadd33 100644 --- a/litellm/llms/voyage/rerank/transformation.py +++ b/litellm/llms/voyage/rerank/transformation.py @@ -11,7 +11,7 @@ import httpx from litellm.llms.base_llm.chat.transformation import LiteLLMLoggingObj from litellm.llms.base_llm.rerank.transformation import BaseRerankConfig -from litellm.secret_managers.main import get_secret_str +from litellm.llms.voyage.common_utils import get_default_base_url, get_voyage_api_key from litellm.types.rerank import ( RerankBilledUnits, RerankResponse, @@ -59,7 +59,7 @@ class VoyageRerankConfig(BaseRerankConfig): optional_params: dict | None = None, ) -> str: if api_base is None: - return "https://api.voyageai.com/v1/rerank" + return f"{get_default_base_url()}/rerank" api_base = api_base.rstrip("/") if not api_base.endswith("/v1/rerank"): if api_base.endswith("/v1"): @@ -140,12 +140,11 @@ class VoyageRerankConfig(BaseRerankConfig): optional_params: dict | None = None, litellm_params: Mapping[str, object] | None = None, ) -> dict: - if api_key is None: - api_key = get_secret_str("VOYAGE_API_KEY") or get_secret_str("VOYAGE_AI_API_KEY") - if api_key is None: + resolved_api_key: Final = get_voyage_api_key(api_key) + if resolved_api_key is None: raise ValueError("Voyage AI API key is required. Set via `api_key` parameter or `VOYAGE_API_KEY` env var.") return { - "Authorization": f"Bearer {api_key}", + "Authorization": f"Bearer {resolved_api_key}", "content-type": "application/json", } diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 8177b6ce142..4273ec54472 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -56413,26 +56413,6 @@ "output_vector_size": 1024, "source": "https://docs.voyageai.com/docs/pricing" }, - "voyage/voyage-4-nano": { - "input_cost_per_token": 0.0, - "litellm_provider": "voyage", - "max_input_tokens": 32000, - "max_tokens": 32000, - "mode": "embedding", - "output_cost_per_token": 0.0, - "output_vector_size": 1024, - "source": "https://docs.voyageai.com/docs/embeddings" - }, - "voyage/voyage-multilingual-2": { - "input_cost_per_token": 1.2e-07, - "litellm_provider": "voyage", - "max_input_tokens": 32000, - "max_tokens": 32000, - "mode": "embedding", - "output_cost_per_token": 0.0, - "output_vector_size": 1024, - "source": "https://docs.voyageai.com/docs/pricing" - }, "voyage/voyage-context-4": { "input_cost_per_token": 1.2e-07, "litellm_provider": "voyage", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 8177b6ce142..4273ec54472 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -56413,26 +56413,6 @@ "output_vector_size": 1024, "source": "https://docs.voyageai.com/docs/pricing" }, - "voyage/voyage-4-nano": { - "input_cost_per_token": 0.0, - "litellm_provider": "voyage", - "max_input_tokens": 32000, - "max_tokens": 32000, - "mode": "embedding", - "output_cost_per_token": 0.0, - "output_vector_size": 1024, - "source": "https://docs.voyageai.com/docs/embeddings" - }, - "voyage/voyage-multilingual-2": { - "input_cost_per_token": 1.2e-07, - "litellm_provider": "voyage", - "max_input_tokens": 32000, - "max_tokens": 32000, - "mode": "embedding", - "output_cost_per_token": 0.0, - "output_vector_size": 1024, - "source": "https://docs.voyageai.com/docs/pricing" - }, "voyage/voyage-context-4": { "input_cost_per_token": 1.2e-07, "litellm_provider": "voyage", diff --git a/tests/llm_translation/test_voyage_ai.py b/tests/llm_translation/test_voyage_ai.py index 96fb5974ee4..06828235173 100644 --- a/tests/llm_translation/test_voyage_ai.py +++ b/tests/llm_translation/test_voyage_ai.py @@ -248,7 +248,7 @@ class TestVoyageContextualEmbeddings: assert "input_type" not in transformed def test_contextual_embedding_str_input_wrapped_with_auto_chunk(self): - """A bare str is wrapped to a one-element list and, as documents, gets auto-chunking + input_type=document""" + """A bare str is wrapped to a one-element sequence and, as documents, gets auto-chunking + input_type=document""" from litellm.llms.voyage.embedding.transformation_contextual import ( VoyageContextualEmbeddingConfig, ) @@ -259,7 +259,7 @@ class TestVoyageContextualEmbeddings: "voyage-context-4", "just one chunk", {}, {} ) - assert transformed["inputs"] == ["just one chunk"] + assert list(transformed["inputs"]) == ["just one chunk"] assert transformed["input_type"] == "document" assert transformed["enable_auto_chunking"] is True @@ -516,24 +516,3 @@ class TestVoyageContextualEmbeddings: except Exception as e: pytest.fail(f"Error occurred: {e}") - - -def test_voyage_current_models_registered(): - """The models currently listed on docs.voyageai.com resolve with voyage pricing/context""" - from litellm import get_model_info - - os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" - litellm.model_cost = litellm.get_model_cost_map(url="") - - expected = { - "voyage/voyage-4-nano": {"max_input_tokens": 32000, "input_cost_per_token": 0.0}, - "voyage/voyage-multilingual-2": {"max_input_tokens": 32000, "input_cost_per_token": 1.2e-07}, - "voyage/voyage-context-4": {"max_input_tokens": 120000, "input_cost_per_token": 1.2e-07}, - } - - for model, fields in expected.items(): - info = get_model_info(model) - assert info["litellm_provider"] == "voyage", f"{model} wrong provider" - assert info["mode"] == "embedding", f"{model} wrong mode" - assert info["max_input_tokens"] == fields["max_input_tokens"], f"{model} wrong context" - assert info["input_cost_per_token"] == fields["input_cost_per_token"], f"{model} wrong price" diff --git a/tests/test_litellm/llms/voyage/rerank/test_voyage_rerank_transformation.py b/tests/test_litellm/llms/voyage/rerank/test_voyage_rerank_transformation.py index f466b7e19b5..de006ca6d50 100644 --- a/tests/test_litellm/llms/voyage/rerank/test_voyage_rerank_transformation.py +++ b/tests/test_litellm/llms/voyage/rerank/test_voyage_rerank_transformation.py @@ -266,11 +266,10 @@ class TestVoyageRerankTransform: assert "top_n" in supported_params assert "return_documents" in supported_params - @patch("litellm.llms.voyage.rerank.transformation.get_secret_str") - def test_validate_environment_missing_api_key(self, mock_get_secret_str): + def test_validate_environment_missing_api_key(self, monkeypatch): """Test that validate_environment raises error when API key is missing.""" - # Mock get_secret_str to return None for both environment variables - mock_get_secret_str.return_value = None + for env_var in ("VOYAGE_API_KEY", "VOYAGE_AI_API_KEY", "VOYAGE_AI_TOKEN"): + monkeypatch.delenv(env_var, raising=False) with pytest.raises(ValueError, match="Voyage AI API key is required"): self.config.validate_environment( headers={}, diff --git a/tests/test_litellm/llms/voyage/test_common_utils.py b/tests/test_litellm/llms/voyage/test_common_utils.py new file mode 100644 index 00000000000..cfae86c2edb --- /dev/null +++ b/tests/test_litellm/llms/voyage/test_common_utils.py @@ -0,0 +1,117 @@ +import os +import sys + +import pytest + +sys.path.insert(0, os.path.abspath("../../../../..")) + +from litellm.llms.voyage.common_utils import ( + MONGODB_API_BASE, + VOYAGE_API_BASE, + get_default_base_url, + get_voyage_api_key, +) +from litellm.llms.voyage.embedding.transformation import VoyageEmbeddingConfig +from litellm.llms.voyage.embedding.transformation_contextual import ( + VoyageContextualEmbeddingConfig, +) +from litellm.llms.voyage.embedding.transformation_multimodal import ( + VoyageMultimodalEmbeddingConfig, +) +from litellm.llms.voyage.rerank.transformation import VoyageRerankConfig + +VOYAGE_KEY_ENV_VARS = ("VOYAGE_API_KEY", "VOYAGE_AI_API_KEY", "VOYAGE_AI_TOKEN") + + +@pytest.fixture(autouse=True) +def clear_voyage_env(monkeypatch): + for env_var in VOYAGE_KEY_ENV_VARS: + monkeypatch.delenv(env_var, raising=False) + + +def test_mongodb_key_routes_to_mongodb_host(): + """MongoDB-issued keys carry the `al-` prefix and are only valid on ai.mongodb.com""" + assert get_default_base_url("al-1234567890") == MONGODB_API_BASE + + +@pytest.mark.parametrize("api_key", ["pa-1234567890", "sk-1234567890", "al", "", None]) +def test_non_mongodb_key_routes_to_voyage_host(api_key): + assert get_default_base_url(api_key) == VOYAGE_API_BASE + + +@pytest.mark.parametrize("env_var", VOYAGE_KEY_ENV_VARS) +def test_mongodb_key_from_any_supported_env_var_routes_to_mongodb_host(monkeypatch, env_var): + monkeypatch.setenv(env_var, "al-from-env") + assert get_default_base_url() == MONGODB_API_BASE + + +def test_explicit_key_wins_over_env_for_routing(monkeypatch): + monkeypatch.setenv("VOYAGE_API_KEY", "al-from-env") + assert get_default_base_url("pa-explicit") == VOYAGE_API_BASE + + +@pytest.mark.parametrize( + "config, endpoint", + [ + (VoyageEmbeddingConfig(), "embeddings"), + (VoyageContextualEmbeddingConfig(), "contextualizedembeddings"), + (VoyageMultimodalEmbeddingConfig(), "multimodalembeddings"), + ], +) +@pytest.mark.parametrize("api_key, expected_host", [("al-key", MONGODB_API_BASE), ("pa-key", VOYAGE_API_BASE)]) +def test_embedding_configs_route_by_key_prefix(config, endpoint, api_key, expected_host): + url = config.get_complete_url(None, api_key, "voyage-3", {}, {}) + assert url == f"{expected_host}/{endpoint}" + + +@pytest.mark.parametrize( + "config, endpoint", + [ + (VoyageEmbeddingConfig(), "embeddings"), + (VoyageContextualEmbeddingConfig(), "contextualizedembeddings"), + (VoyageMultimodalEmbeddingConfig(), "multimodalembeddings"), + ], +) +def test_explicit_api_base_overrides_key_routing(config, endpoint): + url = config.get_complete_url("https://gateway.internal/v1", "al-key", "voyage-3", {}, {}) + assert url == f"https://gateway.internal/v1/{endpoint}" + + +@pytest.mark.parametrize("api_key, expected_host", [("al-key", MONGODB_API_BASE), ("pa-key", VOYAGE_API_BASE)]) +def test_rerank_routes_by_env_key_prefix(monkeypatch, api_key, expected_host): + monkeypatch.setenv("VOYAGE_API_KEY", api_key) + assert VoyageRerankConfig().get_complete_url(None, "rerank-2.5") == f"{expected_host}/rerank" + + +@pytest.mark.parametrize( + "config", + [VoyageEmbeddingConfig(), VoyageContextualEmbeddingConfig(), VoyageMultimodalEmbeddingConfig()], +) +def test_auth_header_uses_the_key_the_url_was_routed_on(monkeypatch, config): + """The host is picked from a key, so the Authorization header has to carry that same key""" + monkeypatch.setenv("VOYAGE_AI_TOKEN", "al-from-env") + + headers = config.validate_environment({}, "voyage-3", [], {}, {}) + url = config.get_complete_url(None, None, "voyage-3", {}, {}) + + assert headers["Authorization"] == "Bearer al-from-env" + assert url.startswith(MONGODB_API_BASE) + + +def test_rerank_auth_header_uses_the_key_the_url_was_routed_on(monkeypatch): + monkeypatch.setenv("VOYAGE_AI_TOKEN", "al-from-env") + + headers = VoyageRerankConfig().validate_environment({}, "rerank-2.5") + + assert headers["Authorization"] == "Bearer al-from-env" + assert VoyageRerankConfig().get_complete_url(None, "rerank-2.5").startswith(MONGODB_API_BASE) + + +def test_get_voyage_api_key_prefers_env_vars_in_documented_order(monkeypatch): + monkeypatch.setenv("VOYAGE_AI_API_KEY", "second") + monkeypatch.setenv("VOYAGE_AI_TOKEN", "third") + assert get_voyage_api_key() == "second" + + monkeypatch.setenv("VOYAGE_API_KEY", "first") + assert get_voyage_api_key() == "first" + assert get_voyage_api_key("explicit") == "explicit" diff --git a/tests/test_litellm/llms/voyage/test_voyage_multimodal_embedding.py b/tests/test_litellm/llms/voyage/test_voyage_multimodal_embedding.py index f3e6885cbe6..d13610ada17 100644 --- a/tests/test_litellm/llms/voyage/test_voyage_multimodal_embedding.py +++ b/tests/test_litellm/llms/voyage/test_voyage_multimodal_embedding.py @@ -172,15 +172,12 @@ class TestVoyageMultimodalEmbeddings: assert headers == {"Authorization": "Bearer test-key"} def test_validate_environment_uses_secret_fallback(self, monkeypatch): - import litellm.llms.voyage.embedding.transformation_multimodal as module from litellm.llms.voyage.embedding.transformation_multimodal import ( VoyageMultimodalEmbeddingConfig, ) - def fake_get_secret(name): - return "secret-key" if name == "VOYAGE_AI_API_KEY" else None - - monkeypatch.setattr(module, "get_secret_str", fake_get_secret) + monkeypatch.delenv("VOYAGE_API_KEY", raising=False) + monkeypatch.setenv("VOYAGE_AI_API_KEY", "secret-key") config = VoyageMultimodalEmbeddingConfig() headers = config.validate_environment( {}, "voyage-multimodal-3.5", [], {}, {}, api_key=None @@ -188,12 +185,12 @@ class TestVoyageMultimodalEmbeddings: assert headers == {"Authorization": "Bearer secret-key"} def test_validate_environment_raises_without_api_key(self, monkeypatch): - import litellm.llms.voyage.embedding.transformation_multimodal as module from litellm.llms.voyage.embedding.transformation_multimodal import ( VoyageMultimodalEmbeddingConfig, ) - monkeypatch.setattr(module, "get_secret_str", lambda name: None) + for env_var in ("VOYAGE_API_KEY", "VOYAGE_AI_API_KEY", "VOYAGE_AI_TOKEN"): + monkeypatch.delenv(env_var, raising=False) config = VoyageMultimodalEmbeddingConfig() with pytest.raises(ValueError, match='Voyage API key is required for multimodal embeddings\\. Set') as exc_info: config.validate_environment( From c86af7d8e425702876335bba26249691e1510a31 Mon Sep 17 00:00:00 2001 From: fzowl Date: Fri, 18 Sep 2026 15:23:58 +0200 Subject: [PATCH 4/9] fix(voyage): restore MongoDB routing on contextual endpoint and re-add voyage-4-nano after upstream merge The upstream merge replaced the contextual config's get_complete_url with a Voyage-only host and dropped voyage-4-nano from the model map. Reapply the al- key routing via get_default_base_url and add voyage-4-nano (open-weight, per docs.voyageai.com) so the branch keeps its task changes on top of upstream. --- .../voyage/embedding/transformation_contextual.py | 12 +++--------- litellm/model_prices_and_context_window_backup.json | 10 ++++++++++ model_prices_and_context_window.json | 10 ++++++++++ 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/litellm/llms/voyage/embedding/transformation_contextual.py b/litellm/llms/voyage/embedding/transformation_contextual.py index 870de8756bb..f5e7fdb48f5 100644 --- a/litellm/llms/voyage/embedding/transformation_contextual.py +++ b/litellm/llms/voyage/embedding/transformation_contextual.py @@ -11,7 +11,7 @@ import httpx from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.embedding.transformation import BaseEmbeddingConfig -from litellm.secret_managers.main import get_secret_str +from litellm.llms.voyage.common_utils import get_default_base_url, get_voyage_api_key from litellm.types.llms.openai import AllEmbeddingInputValues, AllMessageValues from litellm.types.utils import EmbeddingResponse, Usage @@ -58,7 +58,7 @@ class VoyageContextualEmbeddingConfig(BaseEmbeddingConfig): if not api_base.endswith("/contextualizedembeddings"): api_base = f"{api_base}/contextualizedembeddings" return api_base - return "https://api.voyageai.com/v1/contextualizedembeddings" + return f"{get_default_base_url(api_key)}/contextualizedembeddings" def get_supported_openai_params(self, model: str) -> list: # mutable-ok: base class signature return ["encoding_format", "dimensions"] @@ -91,14 +91,8 @@ class VoyageContextualEmbeddingConfig(BaseEmbeddingConfig): api_key: str | None = None, api_base: str | None = None, ) -> dict: - if api_key is None: - api_key = ( - get_secret_str("VOYAGE_API_KEY") - or get_secret_str("VOYAGE_AI_API_KEY") - or get_secret_str("VOYAGE_AI_TOKEN") - ) return { - "Authorization": f"Bearer {api_key}", + "Authorization": f"Bearer {get_voyage_api_key(api_key)}", } AUTO_CHUNK_SIZE: Final = 32000 diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 7191a33a74a..afeb76c275e 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -60293,6 +60293,16 @@ "output_vector_size": 1024, "source": "https://docs.voyageai.com/docs/pricing" }, + "voyage/voyage-4-nano": { + "input_cost_per_token": 0.0, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.voyageai.com/docs/embeddings" + }, "voyage/voyage-code-4": { "input_cost_per_token": 1.2e-07, "litellm_provider": "voyage", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 7191a33a74a..afeb76c275e 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -60293,6 +60293,16 @@ "output_vector_size": 1024, "source": "https://docs.voyageai.com/docs/pricing" }, + "voyage/voyage-4-nano": { + "input_cost_per_token": 0.0, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.voyageai.com/docs/embeddings" + }, "voyage/voyage-code-4": { "input_cost_per_token": 1.2e-07, "litellm_provider": "voyage", From f10625ab70ef9b26cb91f3545d79adc4ddba60b1 Mon Sep 17 00:00:00 2001 From: fzowl Date: Fri, 18 Sep 2026 15:35:55 +0200 Subject: [PATCH 5/9] fix(voyage): drop voyage-4-nano and the contextual tests upstream already covers voyage-4-nano is not served on the Voyage API, so it does not belong in the model map. The contextual input tests duplicate tests/test_litellm/llms/voyage/test_voyage_contextual_embedding.py, which landed upstream with the auto-chunking fix this branch was carrying. --- ...odel_prices_and_context_window_backup.json | 10 --- model_prices_and_context_window.json | 10 --- tests/llm_translation/test_voyage_ai.py | 88 ------------------- 3 files changed, 108 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index afeb76c275e..7191a33a74a 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -60293,16 +60293,6 @@ "output_vector_size": 1024, "source": "https://docs.voyageai.com/docs/pricing" }, - "voyage/voyage-4-nano": { - "input_cost_per_token": 0.0, - "litellm_provider": "voyage", - "max_input_tokens": 32000, - "max_tokens": 32000, - "mode": "embedding", - "output_cost_per_token": 0.0, - "output_vector_size": 1024, - "source": "https://docs.voyageai.com/docs/embeddings" - }, "voyage/voyage-code-4": { "input_cost_per_token": 1.2e-07, "litellm_provider": "voyage", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index afeb76c275e..7191a33a74a 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -60293,16 +60293,6 @@ "output_vector_size": 1024, "source": "https://docs.voyageai.com/docs/pricing" }, - "voyage/voyage-4-nano": { - "input_cost_per_token": 0.0, - "litellm_provider": "voyage", - "max_input_tokens": 32000, - "max_tokens": 32000, - "mode": "embedding", - "output_cost_per_token": 0.0, - "output_vector_size": 1024, - "source": "https://docs.voyageai.com/docs/embeddings" - }, "voyage/voyage-code-4": { "input_cost_per_token": 1.2e-07, "litellm_provider": "voyage", diff --git a/tests/llm_translation/test_voyage_ai.py b/tests/llm_translation/test_voyage_ai.py index c940b97725b..800751be115 100644 --- a/tests/llm_translation/test_voyage_ai.py +++ b/tests/llm_translation/test_voyage_ai.py @@ -196,94 +196,6 @@ class TestVoyageContextualEmbeddings: assert transformed["model"] == "voyage-context-3" assert transformed["encoding_format"] == "float" - def test_contextual_embedding_flat_list_defaults_to_document_auto_chunk(self): - """A flat list[str] with no input_type is documents, so it needs auto-chunking + input_type=document""" - from litellm.llms.voyage.embedding.transformation_contextual import ( - VoyageContextualEmbeddingConfig, - ) - - config = VoyageContextualEmbeddingConfig() - flat_input = ["chunk one", "chunk two"] - - transformed = config.transform_embedding_request( - "voyage-context-4", flat_input, {}, {} - ) - - assert transformed["inputs"] == flat_input - assert transformed["model"] == "voyage-context-4" - assert transformed["input_type"] == "document" - assert transformed["enable_auto_chunking"] is True - - def test_contextual_embedding_flat_list_query_stays_flat_without_auto_chunk(self): - """A flat list[str] of queries is valid as-is, so no auto-chunking must be forced on""" - from litellm.llms.voyage.embedding.transformation_contextual import ( - VoyageContextualEmbeddingConfig, - ) - - config = VoyageContextualEmbeddingConfig() - flat_input = ["what is voyage", "who owns voyage"] - - transformed = config.transform_embedding_request( - "voyage-context-4", flat_input, {"input_type": "query"}, {} - ) - - assert transformed["inputs"] == flat_input - assert transformed["input_type"] == "query" - assert "enable_auto_chunking" not in transformed - - def test_contextual_embedding_nested_list_input_passes_through(self): - """A nested list[list[str]] is pre-chunked documents, valid unchanged with no extra params""" - from litellm.llms.voyage.embedding.transformation_contextual import ( - VoyageContextualEmbeddingConfig, - ) - - config = VoyageContextualEmbeddingConfig() - nested_input = [["doc a chunk 1", "doc a chunk 2"], ["doc b chunk 1"]] - - transformed = config.transform_embedding_request( - "voyage-context-4", nested_input, {}, {} - ) - - assert transformed["inputs"] == nested_input - assert "enable_auto_chunking" not in transformed - assert "input_type" not in transformed - - def test_contextual_embedding_str_input_wrapped_with_auto_chunk(self): - """A bare str is wrapped to a one-element sequence and, as documents, gets auto-chunking + input_type=document""" - from litellm.llms.voyage.embedding.transformation_contextual import ( - VoyageContextualEmbeddingConfig, - ) - - config = VoyageContextualEmbeddingConfig() - - transformed = config.transform_embedding_request( - "voyage-context-4", "just one chunk", {}, {} - ) - - assert list(transformed["inputs"]) == ["just one chunk"] - assert transformed["input_type"] == "document" - assert transformed["enable_auto_chunking"] is True - - def test_contextual_embedding_caller_params_win(self): - """Caller-set input_type=document with explicit auto-chunking off must be respected, not overridden""" - from litellm.llms.voyage.embedding.transformation_contextual import ( - VoyageContextualEmbeddingConfig, - ) - - config = VoyageContextualEmbeddingConfig() - flat_input = ["chunk a", "chunk b"] - - transformed = config.transform_embedding_request( - "voyage-context-4", - flat_input, - {"input_type": "document", "enable_auto_chunking": False}, - {}, - ) - - assert transformed["inputs"] == flat_input - assert transformed["input_type"] == "document" - assert transformed["enable_auto_chunking"] is False - def test_contextual_embedding_response_transformation(self): """Test response transformation for contextual embeddings""" from litellm.llms.voyage.embedding.transformation_contextual import ( From cac4dc2231ffd590a2e5f9ba4be6b212c5695927 Mon Sep 17 00:00:00 2001 From: fzowl Date: Fri, 18 Sep 2026 15:44:51 +0200 Subject: [PATCH 6/9] test(voyage): drop sys.path.insert from the voyage common utils test --- tests/test_litellm/llms/voyage/test_common_utils.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/test_litellm/llms/voyage/test_common_utils.py b/tests/test_litellm/llms/voyage/test_common_utils.py index cfae86c2edb..c475bedeaba 100644 --- a/tests/test_litellm/llms/voyage/test_common_utils.py +++ b/tests/test_litellm/llms/voyage/test_common_utils.py @@ -1,10 +1,5 @@ -import os -import sys - import pytest -sys.path.insert(0, os.path.abspath("../../../../..")) - from litellm.llms.voyage.common_utils import ( MONGODB_API_BASE, VOYAGE_API_BASE, From fcd9a894d969b68278228d9b79285d46536eed31 Mon Sep 17 00:00:00 2001 From: fzowl Date: Fri, 18 Sep 2026 19:21:43 +0200 Subject: [PATCH 7/9] fix(voyage): route rerank on the key it authenticates with get_complete_url picked the rerank host from the environment while the auth header carried the request key, so an explicit MongoDB-issued key was posted to api.voyageai.com. validate_environment now hands the resolved key to the config instance and get_complete_url reads it back, so the host and the credential always come from one key. The config is built per request, so nothing carries over between them. --- litellm/llms/voyage/rerank/transformation.py | 13 ++++- .../llms/voyage/test_common_utils.py | 58 ++++++++++++++++++- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/litellm/llms/voyage/rerank/transformation.py b/litellm/llms/voyage/rerank/transformation.py index 7a9ec94feb7..c302509ea35 100644 --- a/litellm/llms/voyage/rerank/transformation.py +++ b/litellm/llms/voyage/rerank/transformation.py @@ -25,6 +25,16 @@ from ..embedding.transformation import VoyageError class VoyageRerankConfig(BaseRerankConfig): + """ + ``validate_environment`` stores the credential it authenticates with so ``get_complete_url`` + can select the host that issued it. ``ProviderConfigManager.get_provider_rerank_config`` + builds this config per request, so that key never reaches another one. + """ + + def __init__(self) -> None: + super().__init__() + self._api_key: str | None = None + def get_supported_cohere_rerank_params(self, model: str) -> list: return ["query", "documents", "top_n", "return_documents"] @@ -60,7 +70,7 @@ class VoyageRerankConfig(BaseRerankConfig): optional_params: dict | None = None, ) -> str: if api_base is None: - return f"{get_default_base_url()}/rerank" + return f"{get_default_base_url(self._api_key)}/rerank" api_base = api_base.rstrip("/") if not api_base.endswith("/v1/rerank"): if api_base.endswith("/v1"): @@ -144,6 +154,7 @@ class VoyageRerankConfig(BaseRerankConfig): resolved_api_key: Final = get_voyage_api_key(api_key) if resolved_api_key is None: raise ValueError("Voyage AI API key is required. Set via `api_key` parameter or `VOYAGE_API_KEY` env var.") + self._api_key = resolved_api_key return { "Authorization": f"Bearer {resolved_api_key}", "content-type": "application/json", diff --git a/tests/test_litellm/llms/voyage/test_common_utils.py b/tests/test_litellm/llms/voyage/test_common_utils.py index c475bedeaba..511391093f3 100644 --- a/tests/test_litellm/llms/voyage/test_common_utils.py +++ b/tests/test_litellm/llms/voyage/test_common_utils.py @@ -72,10 +72,61 @@ def test_explicit_api_base_overrides_key_routing(config, endpoint): assert url == f"https://gateway.internal/v1/{endpoint}" +@pytest.mark.parametrize("api_key, expected_host", [("al-key", MONGODB_API_BASE), ("pa-key", VOYAGE_API_BASE)]) +def test_rerank_routes_by_request_key_prefix(api_key, expected_host): + config = VoyageRerankConfig() + config.validate_environment({}, "rerank-2.5", api_key=api_key) + + assert config.get_complete_url(None, "rerank-2.5") == f"{expected_host}/rerank" + + @pytest.mark.parametrize("api_key, expected_host", [("al-key", MONGODB_API_BASE), ("pa-key", VOYAGE_API_BASE)]) def test_rerank_routes_by_env_key_prefix(monkeypatch, api_key, expected_host): monkeypatch.setenv("VOYAGE_API_KEY", api_key) - assert VoyageRerankConfig().get_complete_url(None, "rerank-2.5") == f"{expected_host}/rerank" + config = VoyageRerankConfig() + config.validate_environment({}, "rerank-2.5") + + assert config.get_complete_url(None, "rerank-2.5") == f"{expected_host}/rerank" + + +def test_rerank_request_key_beats_env_key_for_routing(monkeypatch): + """A MongoDB key on the request must not be posted to the Voyage host the env key names""" + monkeypatch.setenv("VOYAGE_API_KEY", "pa-from-env") + config = VoyageRerankConfig() + + headers = config.validate_environment({}, "rerank-2.5", api_key="al-on-request") + + assert headers["Authorization"] == "Bearer al-on-request" + assert config.get_complete_url(None, "rerank-2.5") == f"{MONGODB_API_BASE}/rerank" + + +def test_rerank_config_is_built_per_request_so_keys_cannot_leak(monkeypatch): + """get_complete_url reads a key off the instance, so each request must get its own instance""" + import litellm + from litellm.types.utils import LlmProviders + from litellm.utils import ProviderConfigManager + + monkeypatch.delenv("VOYAGE_API_KEY", raising=False) + first = ProviderConfigManager.get_provider_rerank_config( + model="rerank-2.5", provider=LlmProviders.VOYAGE, api_base=None, present_version_params=[] + ) + second = ProviderConfigManager.get_provider_rerank_config( + model="rerank-2.5", provider=LlmProviders.VOYAGE, api_base=None, present_version_params=[] + ) + assert isinstance(first, litellm.VoyageRerankConfig) and first is not second + + first.validate_environment({}, "rerank-2.5", api_key="al-first-request") + second.validate_environment({}, "rerank-2.5", api_key="pa-second-request") + + assert first.get_complete_url(None, "rerank-2.5") == f"{MONGODB_API_BASE}/rerank" + assert second.get_complete_url(None, "rerank-2.5") == f"{VOYAGE_API_BASE}/rerank" + + +def test_rerank_falls_back_to_env_when_validate_environment_did_not_run(monkeypatch): + """A caller that skips validate_environment keeps the pre-existing env-only behaviour""" + monkeypatch.setenv("VOYAGE_API_KEY", "al-from-env") + + assert VoyageRerankConfig().get_complete_url(None, "rerank-2.5") == f"{MONGODB_API_BASE}/rerank" @pytest.mark.parametrize( @@ -95,11 +146,12 @@ def test_auth_header_uses_the_key_the_url_was_routed_on(monkeypatch, config): def test_rerank_auth_header_uses_the_key_the_url_was_routed_on(monkeypatch): monkeypatch.setenv("VOYAGE_AI_TOKEN", "al-from-env") + config = VoyageRerankConfig() - headers = VoyageRerankConfig().validate_environment({}, "rerank-2.5") + headers = config.validate_environment({}, "rerank-2.5") assert headers["Authorization"] == "Bearer al-from-env" - assert VoyageRerankConfig().get_complete_url(None, "rerank-2.5").startswith(MONGODB_API_BASE) + assert config.get_complete_url(None, "rerank-2.5").startswith(MONGODB_API_BASE) def test_get_voyage_api_key_prefers_env_vars_in_documented_order(monkeypatch): From e5fa7036fac4efa5806baea68ed7501999e02659 Mon Sep 17 00:00:00 2001 From: fzowl Date: Wed, 30 Sep 2026 16:29:18 +0200 Subject: [PATCH 8/9] test: allow ultrafast pricing keys in model prices schema --- tests/unit/test_utils.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index afc449d22c4..707292b8971 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -769,6 +769,14 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "cache_creation_input_token_cost_above_272k_tokens": {"type": "number"}, "cache_creation_input_token_cost_above_272k_tokens_flex": {"type": "number"}, "cache_creation_input_token_cost_above_272k_tokens_priority": {"type": "number"}, + "cache_creation_input_token_cost_above_272k_tokens_ultrafast": {"type": "number"}, + "cache_creation_input_token_cost_ultrafast": {"type": "number"}, + "cache_read_input_token_cost_above_272k_tokens_ultrafast": {"type": "number"}, + "cache_read_input_token_cost_ultrafast": {"type": "number"}, + "input_cost_per_token_above_272k_tokens_ultrafast": {"type": "number"}, + "input_cost_per_token_ultrafast": {"type": "number"}, + "output_cost_per_token_above_272k_tokens_ultrafast": {"type": "number"}, + "output_cost_per_token_ultrafast": {"type": "number"}, "cache_creation_input_token_cost_above_200k_tokens_batches": {"type": "number"}, "cache_creation_input_token_cost_above_272k_tokens_batches": {"type": "number"}, "cache_creation_input_token_cost_batches": {"type": "number"}, From ed2d5d2e00ceb9925064bc6f8b0070996d2adc16 Mon Sep 17 00:00:00 2001 From: fzowl Date: Wed, 30 Sep 2026 16:49:16 +0200 Subject: [PATCH 9/9] test: drop ultrafast schema keys now added upstream --- tests/unit/test_utils.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 692683b9ea3..52b1714b4d9 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -770,14 +770,6 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "cache_creation_input_token_cost_above_272k_tokens_flex": {"type": "number"}, "cache_creation_input_token_cost_above_272k_tokens_ultrafast": {"type": "number"}, "cache_creation_input_token_cost_above_272k_tokens_priority": {"type": "number"}, - "cache_creation_input_token_cost_above_272k_tokens_ultrafast": {"type": "number"}, - "cache_creation_input_token_cost_ultrafast": {"type": "number"}, - "cache_read_input_token_cost_above_272k_tokens_ultrafast": {"type": "number"}, - "cache_read_input_token_cost_ultrafast": {"type": "number"}, - "input_cost_per_token_above_272k_tokens_ultrafast": {"type": "number"}, - "input_cost_per_token_ultrafast": {"type": "number"}, - "output_cost_per_token_above_272k_tokens_ultrafast": {"type": "number"}, - "output_cost_per_token_ultrafast": {"type": "number"}, "cache_creation_input_token_cost_above_200k_tokens_batches": {"type": "number"}, "cache_creation_input_token_cost_above_272k_tokens_batches": {"type": "number"}, "cache_creation_input_token_cost_batches": {"type": "number"},