This commit is contained in:
fzowl 2026-10-03 18:39:07 +02:00 • committed by GitHub
commit 26375370e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 238 additions and 50 deletions

View file

@ -399,7 +399,7 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th
| [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) | ✅ | ✅ | ✅ | | | | | | | |

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

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

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

@ -12,7 +12,7 @@ import httpx
from litellm._uuid import uuid
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,
@ -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 "https://api.voyageai.com/v1/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"):
@ -141,12 +151,12 @@ 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.")
self._api_key = resolved_api_key
return {
"Authorization": f"Bearer {api_key}",
"Authorization": f"Bearer {resolved_api_key}",
"content-type": "application/json",
}

View file

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

View file

@ -3499,7 +3499,7 @@
},
{
"provider": "Voyage",
"provider_display_name": "Voyage AI",
"provider_display_name": "VoyageAI by MongoDB",
"litellm_provider": "voyage",
"credential_fields": [
{

View file

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

View file

@ -294,11 +294,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,164 @@
import pytest
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_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)
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(
"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")
config = VoyageRerankConfig()
headers = config.validate_environment({}, "rerank-2.5")
assert headers["Authorization"] == "Bearer al-from-env"
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):
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(

View file

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