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.
This commit is contained in:
fzowl 2026-09-18 14:49:55 +02:00
parent 236d769827
commit 9c89ab96aa
11 changed files with 196 additions and 138 deletions

View file

@ -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

View file

@ -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(

View file

@ -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,

View file

@ -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")

View file

@ -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",
}

View file

@ -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",

View file

@ -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",

View file

@ -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"

View file

@ -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={},

View file

@ -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"

View file

@ -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(