feat(voyage): add voyage-4 family + voyage-context-4, fix contextual list[str] input

- Add voyage-4, voyage-4-large, voyage-4-lite, voyage-4-nano (/bin/bash) and
  voyage-context-4 to the cost/context map (both root and backup json).
- Contextual embeddings: normalize flat list[str] input. Voyage rejects a
  flat list[str] unless input_type='query', so wrap each string as its own
  single-chunk document otherwise; single str -> [[str]]; list[list[str]]
  passes through. Keeps list[str] when input_type='query'.
- Add tests for input normalization and voyage-4 family pricing.
This commit is contained in:
fzowl 2026-07-29 14:31:25 +02:00
parent 5bb7394c93
commit f88dd65590
4 changed files with 197 additions and 1 deletions

View file

@ -106,11 +106,42 @@ class VoyageContextualEmbeddingConfig(BaseEmbeddingConfig):
headers: dict,
) -> dict:
return {
"inputs": input,
"inputs": self._transform_contextual_inputs(input, optional_params),
"model": model,
**optional_params,
}
@staticmethod
def _transform_contextual_inputs(
input: Union[AllEmbeddingInputValues, List[List[str]]],
optional_params: dict,
) -> List[List[str]]:
"""
Voyage's contextualized embeddings API expects ``inputs`` to be a
``list[list[str]]`` (each inner list is a document made of chunks that
share context).
It also accepts a flat ``list[str]`` - but only when
``input_type == "query"``. In every other case a flat list must be
wrapped so each string becomes its own single-chunk document, otherwise
the API rejects the request with a 400.
Reference: https://docs.voyageai.com/reference/contextualized-embeddings-api
"""
# Single string -> one document with a single chunk.
if isinstance(input, str):
return [[input]]
# Flat list[str]: keep as list[str] when the API allows it
# (input_type="query"), otherwise wrap each string as its own document.
if isinstance(input, list) and all(isinstance(i, str) for i in input):
if optional_params.get("input_type") == "query":
return input # type: ignore[return-value]
return [[i] for i in input]
# Already list[list[str]] (or another shape) -> pass through unchanged.
return input # type: ignore[return-value]
def transform_embedding_response(
self,
model: str,

View file

@ -27345,6 +27345,38 @@
"mode": "embedding",
"output_cost_per_token": 0.0
},
"voyage/voyage-4": {
"input_cost_per_token": 6e-08,
"litellm_provider": "voyage",
"max_input_tokens": 32000,
"max_tokens": 32000,
"mode": "embedding",
"output_cost_per_token": 0.0
},
"voyage/voyage-4-large": {
"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
},
"voyage/voyage-4-lite": {
"input_cost_per_token": 2e-08,
"litellm_provider": "voyage",
"max_input_tokens": 32000,
"max_tokens": 32000,
"mode": "embedding",
"output_cost_per_token": 0.0
},
"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
},
"voyage/voyage-code-2": {
"input_cost_per_token": 1.2e-07,
"litellm_provider": "voyage",
@ -27369,6 +27401,14 @@
"mode": "embedding",
"output_cost_per_token": 0.0
},
"voyage/voyage-context-4": {
"input_cost_per_token": 1.2e-07,
"litellm_provider": "voyage",
"max_input_tokens": 120000,
"max_tokens": 120000,
"mode": "embedding",
"output_cost_per_token": 0.0
},
"voyage/voyage-finance-2": {
"input_cost_per_token": 1.2e-07,
"litellm_provider": "voyage",

View file

@ -27345,6 +27345,38 @@
"mode": "embedding",
"output_cost_per_token": 0.0
},
"voyage/voyage-4": {
"input_cost_per_token": 6e-08,
"litellm_provider": "voyage",
"max_input_tokens": 32000,
"max_tokens": 32000,
"mode": "embedding",
"output_cost_per_token": 0.0
},
"voyage/voyage-4-large": {
"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
},
"voyage/voyage-4-lite": {
"input_cost_per_token": 2e-08,
"litellm_provider": "voyage",
"max_input_tokens": 32000,
"max_tokens": 32000,
"mode": "embedding",
"output_cost_per_token": 0.0
},
"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
},
"voyage/voyage-code-2": {
"input_cost_per_token": 1.2e-07,
"litellm_provider": "voyage",
@ -27369,6 +27401,14 @@
"mode": "embedding",
"output_cost_per_token": 0.0
},
"voyage/voyage-context-4": {
"input_cost_per_token": 1.2e-07,
"litellm_provider": "voyage",
"max_input_tokens": 120000,
"max_tokens": 120000,
"mode": "embedding",
"output_cost_per_token": 0.0
},
"voyage/voyage-finance-2": {
"input_cost_per_token": 1.2e-07,
"litellm_provider": "voyage",

View file

@ -71,6 +71,26 @@ class TestVoyageAI(BaseLLMEmbeddingTest):
assert response.usage.total_tokens > 0
@pytest.mark.parametrize(
"model, expected_input_cost",
[
("voyage/voyage-4", 6e-08),
("voyage/voyage-4-large", 1.2e-07),
("voyage/voyage-4-lite", 2e-08),
("voyage/voyage-4-nano", 0.0),
("voyage/voyage-context-4", 1.2e-07),
],
)
def test_voyage_4_family_pricing_registered(model, expected_input_cost):
"""The voyage-4 family and voyage-context-4 must be in the cost map."""
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
info = litellm.get_model_info(model=model)
assert info["litellm_provider"] == "voyage"
assert info["mode"] == "embedding"
assert info["input_cost_per_token"] == expected_input_cost
def test_voyage_ai_embedding_extra_params():
"""Test Voyage AI embedding with extra parameters"""
try:
@ -142,6 +162,7 @@ class TestVoyageContextualEmbeddings:
# Test contextual model detection
assert config.is_contextualized_embeddings("voyage-context-3") is True
assert config.is_contextualized_embeddings("voyage-context-4") is True
assert config.is_contextualized_embeddings("voyage-context-2") is True
assert config.is_contextualized_embeddings("context-model") is True
@ -198,6 +219,70 @@ class TestVoyageContextualEmbeddings:
assert transformed["model"] == "voyage-context-3"
assert transformed["encoding_format"] == "float"
def test_contextual_flat_list_str_is_wrapped(self):
"""Flat list[str] without input_type must be wrapped to list[list[str]]."""
from litellm.llms.voyage.embedding.transformation_contextual import (
VoyageContextualEmbeddingConfig,
)
config = VoyageContextualEmbeddingConfig()
transformed = config.transform_embedding_request(
"voyage-context-4", ["Hello", "world"], {}, {}
)
# Voyage rejects a flat list[str] unless input_type="query", so each
# string becomes its own single-chunk document.
assert transformed["inputs"] == [["Hello"], ["world"]]
assert transformed["model"] == "voyage-context-4"
def test_contextual_flat_list_str_query_stays_flat(self):
"""Flat list[str] with input_type='query' is sent as-is (list[str])."""
from litellm.llms.voyage.embedding.transformation_contextual import (
VoyageContextualEmbeddingConfig,
)
config = VoyageContextualEmbeddingConfig()
transformed = config.transform_embedding_request(
"voyage-context-4",
["Hello", "world"],
{"input_type": "query"},
{},
)
assert transformed["inputs"] == ["Hello", "world"]
assert transformed["input_type"] == "query"
def test_contextual_single_string_is_wrapped(self):
"""A single string is wrapped to a single document with a single chunk."""
from litellm.llms.voyage.embedding.transformation_contextual import (
VoyageContextualEmbeddingConfig,
)
config = VoyageContextualEmbeddingConfig()
transformed = config.transform_embedding_request(
"voyage-context-4", "Hello", {}, {}
)
assert transformed["inputs"] == [["Hello"]]
def test_contextual_nested_input_passthrough(self):
"""Already-nested list[list[str]] input is passed through unchanged."""
from litellm.llms.voyage.embedding.transformation_contextual import (
VoyageContextualEmbeddingConfig,
)
config = VoyageContextualEmbeddingConfig()
nested = [["Hello", "world"], ["Test"]]
transformed = config.transform_embedding_request(
"voyage-context-4", nested, {}, {}
)
assert transformed["inputs"] == nested
def test_contextual_embedding_response_transformation(self):
"""Test response transformation for contextual embeddings"""
from litellm.llms.voyage.embedding.transformation_contextual import (