feat(bedrock): add TwelveLabs Marengo Embed 3.0 embeddings

This commit is contained in:
mateo-berri 2026-09-07 17:24:17 -07:00
parent 9d0c9b9382
commit dc09d9e7cf
12 changed files with 961 additions and 13 deletions

View file

@ -1370,6 +1370,7 @@ bedrock_embedding_models: Final[set] = set(
"cohere.embed-multilingual-v3",
"cohere.embed-v4:0",
"twelvelabs.marengo-embed-2-7-v1:0",
"twelvelabs.marengo-embed-3-0-v1:0",
]
)

View file

@ -474,7 +474,7 @@ class BedrockEmbedding(BaseAWSLLM):
elif provider == "twelvelabs":
batch_data = []
for i in input:
twelvelabs_request = TwelveLabsMarengoEmbeddingConfig()._transform_request(
twelvelabs_request = TwelveLabsMarengoEmbeddingConfig(model=model)._transform_request(
input=i,
inference_params=inference_params,
async_invoke_route=has_async_invoke,

View file

@ -0,0 +1,204 @@
"""
Request builder for Bedrock TwelveLabs Marengo Embed 3.0, whose payload nests the input under a key named after
``inputType`` instead of the flat 2.7 layout.
Docs - https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-marengo-3.html
"""
from collections.abc import Mapping
from types import MappingProxyType
from typing import Final, assert_never
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
from litellm.llms.bedrock.common_utils import BedrockError
from litellm.types.llms.bedrock import (
TWELVELABS_MARENGO_3_EMBEDDING_OPTIONS,
TWELVELABS_MARENGO_3_EMBEDDING_SCOPES,
TWELVELABS_MARENGO_3_EMBEDDING_TYPES,
TWELVELABS_MARENGO_3_INPUT_TYPES,
TwelveLabsMarengo3AudioRequest,
TwelveLabsMarengo3EmbeddingRequest,
TwelveLabsMarengo3ImageRequest,
TwelveLabsMarengo3MultiInputRequest,
TwelveLabsMarengo3NamedMediaSource,
TwelveLabsMarengo3RequestBase,
TwelveLabsMarengo3Segmentation,
TwelveLabsMarengo3TextImageRequest,
TwelveLabsMarengo3TextRequest,
TwelveLabsMarengo3TimedMediaInput,
TwelveLabsMarengo3TimedMediaOptions,
TwelveLabsMarengo3VideoRequest,
TwelveLabsMediaSource,
TwelveLabsS3Location,
)
from litellm.utils import get_base64_str
MARENGO_3_MODEL_MARKER: Final = "marengo-embed-3"
S3_URI_PREFIX: Final = "s3://"
TIMED_MEDIA_OPTION_FIELDS: Final = MappingProxyType(
{
"startSec": True,
"endSec": True,
"segmentation": True,
"embeddingOption": True,
"embeddingType": True,
"embeddingScope": True,
}
)
TIMED_MEDIA_OPTIONS: Final = TypeAdapter(TwelveLabsMarengo3TimedMediaOptions)
def is_marengo_3_model(model: str | None) -> bool:
return MARENGO_3_MODEL_MARKER in (model or "")
class Marengo3Params(BaseModel):
model_config = ConfigDict(extra="ignore", frozen=True)
inputType: TWELVELABS_MARENGO_3_INPUT_TYPES | None = None
input_type: TWELVELABS_MARENGO_3_INPUT_TYPES | None = None
media_source: str | None = None
media_sources: Mapping[str, str] | None = None
bucketOwner: str | None = None
startSec: float | None = None
endSec: float | None = None
segmentation: TwelveLabsMarengo3Segmentation | None = None
embeddingOption: tuple[TWELVELABS_MARENGO_3_EMBEDDING_OPTIONS, ...] | None = None
embeddingType: tuple[TWELVELABS_MARENGO_3_EMBEDDING_TYPES, ...] | None = None
embeddingScope: tuple[TWELVELABS_MARENGO_3_EMBEDDING_SCOPES, ...] | None = None
inferenceId: str | None = None
@property
def resolved_input_type(self) -> TWELVELABS_MARENGO_3_INPUT_TYPES:
return self.inputType or self.input_type or "text"
def timed_media_options(self) -> TwelveLabsMarengo3TimedMediaOptions:
return TIMED_MEDIA_OPTIONS.validate_python(
self.model_dump(include=TIMED_MEDIA_OPTION_FIELDS, exclude_none=True)
)
def _s3_location(uri: str, bucket_owner: str | None) -> TwelveLabsS3Location:
if bucket_owner is None:
unowned: Final[TwelveLabsS3Location] = {"uri": uri}
return unowned
owned: Final[TwelveLabsS3Location] = {"uri": uri, "bucketOwner": bucket_owner}
return owned
def _media_source(media: str, bucket_owner: str | None) -> TwelveLabsMediaSource:
if not media.startswith(S3_URI_PREFIX):
inline: Final[TwelveLabsMediaSource] = {"base64String": get_base64_str(media)}
return inline
remote: Final[TwelveLabsMediaSource] = {"s3Location": _s3_location(media, bucket_owner)}
return remote
def _named_media_source(name: str, media: str, bucket_owner: str | None) -> TwelveLabsMarengo3NamedMediaSource:
named: Final[TwelveLabsMarengo3NamedMediaSource] = {
"name": name,
"mediaType": "image",
**_media_source(media, bucket_owner),
}
return named
def _timed_media_input(media: str, params: Marengo3Params) -> TwelveLabsMarengo3TimedMediaInput:
timed: Final[TwelveLabsMarengo3TimedMediaInput] = {
"mediaSource": _media_source(media, params.bucketOwner),
**params.timed_media_options(),
}
return timed
def _validated_params(inference_params: Mapping[str, object]) -> Marengo3Params:
try:
return Marengo3Params.model_validate(inference_params)
except ValidationError as error:
raise BedrockError(status_code=400, message=f"Invalid Marengo 3.0 parameters: {error}") from error
def _require(value: str | None, input_type: str, param_name: str) -> str:
if value is None:
raise BedrockError(status_code=400, message=f"Input type '{input_type}' requires the '{param_name}' parameter")
return value
def _require_media_sources(value: Mapping[str, str] | None) -> Mapping[str, str]:
if not value:
raise BedrockError(
status_code=400,
message="Input type 'multi_input' requires a non-empty 'media_sources' mapping of name to media",
)
return value
def _request_base(inference_id: str | None) -> TwelveLabsMarengo3RequestBase:
if inference_id is None:
anonymous: Final[TwelveLabsMarengo3RequestBase] = {}
return anonymous
identified: Final[TwelveLabsMarengo3RequestBase] = {"inferenceId": inference_id}
return identified
def build_marengo_3_request(input: str, inference_params: Mapping[str, object]) -> TwelveLabsMarengo3EmbeddingRequest:
params: Final = _validated_params(inference_params)
base: Final = _request_base(params.inferenceId)
input_type: Final = params.resolved_input_type
match input_type:
case "text":
text_request: Final[TwelveLabsMarengo3TextRequest] = {
**base,
"inputType": "text",
"text": {"inputText": input},
}
return text_request
case "image":
image_request: Final[TwelveLabsMarengo3ImageRequest] = {
**base,
"inputType": "image",
"image": {"mediaSource": _media_source(input, params.bucketOwner)},
}
return image_request
case "video":
video_request: Final[TwelveLabsMarengo3VideoRequest] = {
**base,
"inputType": "video",
"video": _timed_media_input(input, params),
}
return video_request
case "audio":
audio_request: Final[TwelveLabsMarengo3AudioRequest] = {
**base,
"inputType": "audio",
"audio": _timed_media_input(input, params),
}
return audio_request
case "text_image":
text_image_request: Final[TwelveLabsMarengo3TextImageRequest] = {
**base,
"inputType": "text_image",
"text_image": {
"inputText": input,
"mediaSource": _media_source(
_require(params.media_source, input_type, "media_source"), params.bucketOwner
),
},
}
return text_image_request
case "multi_input":
media_sources: Final = tuple(
_named_media_source(name, media, params.bucketOwner)
for name, media in _require_media_sources(params.media_sources).items()
)
multi_input_request: Final[TwelveLabsMarengo3MultiInputRequest] = {
**base,
"inputType": "multi_input",
"multi_input": {"inputText": input, "mediaSources": media_sources}
if input
else {"mediaSources": media_sources},
}
return multi_input_request
case _:
assert_never(input_type)

View file

@ -4,13 +4,19 @@ Transformation logic from OpenAI /v1/embeddings format to Bedrock TwelveLabs Mar
Why separate file? Make it easy to see how transformation works
Docs - https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-marengo.html
Marengo 3.0 docs - https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-marengo-3.html
"""
from typing import Final, cast
from litellm.llms.bedrock.embed.twelvelabs_marengo_3_transformation import (
build_marengo_3_request,
is_marengo_3_model,
)
from litellm.types.llms.bedrock import (
TWELVELABS_EMBEDDING_INPUT_TYPES,
TwelveLabsAsyncInvokeRequest,
TwelveLabsMarengo3EmbeddingRequest,
TwelveLabsMarengoEmbeddingRequest,
TwelveLabsOutputDataConfig,
TwelveLabsS3Location,
@ -26,10 +32,13 @@ class TwelveLabsMarengoEmbeddingConfig:
Supports text, image, video, and audio inputs.
- InvokeModel: text and image inputs
- StartAsyncInvoke: video, audio, image, and text inputs
Marengo 3.0 (model ids containing "marengo-embed-3") nests the input under a key named after inputType and
adds the text_image and multi_input input types; that payload is built by build_marengo_3_request.
"""
def __init__(self) -> None:
pass
def __init__(self, model: str | None = None) -> None:
self.is_marengo_3: Final = is_marengo_3_model(model)
def get_supported_openai_params(self) -> list[str]:
return [
@ -41,13 +50,20 @@ class TwelveLabsMarengoEmbeddingConfig:
"useFixedLengthSec",
"minClipSec",
"input_type",
"endSec",
"segmentation",
"embeddingType",
"embeddingScope",
"inferenceId",
"media_source",
"media_sources",
]
def map_openai_params(self, non_default_params: dict, optional_params: dict) -> dict:
for k, v in non_default_params.items():
if k == "encoding_format":
# TwelveLabs doesn't have encoding_format, but we can map it to embeddingOption
if v == "float":
if v == "float" and not self.is_marengo_3:
optional_params["embeddingOption"] = ["visual-text", "visual-image"]
elif k == "textTruncate":
optional_params["textTruncate"] = v
@ -56,7 +72,19 @@ class TwelveLabsMarengoEmbeddingConfig:
elif k == "input_type":
# Map input_type to inputType for Bedrock
optional_params["inputType"] = v
elif k in ["startSec", "lengthSec", "useFixedLengthSec", "minClipSec"]:
elif k in (
"startSec",
"lengthSec",
"useFixedLengthSec",
"minClipSec",
"endSec",
"segmentation",
"embeddingType",
"embeddingScope",
"inferenceId",
"media_source",
"media_sources",
):
optional_params[k] = v
return optional_params
@ -77,7 +105,7 @@ class TwelveLabsMarengoEmbeddingConfig:
async_invoke_route: bool = False,
model_id: str | None = None,
output_s3_uri: str | None = None,
) -> TwelveLabsMarengoEmbeddingRequest | TwelveLabsAsyncInvokeRequest:
) -> TwelveLabsMarengoEmbeddingRequest | TwelveLabsMarengo3EmbeddingRequest | TwelveLabsAsyncInvokeRequest:
"""
Transform OpenAI-style input to TwelveLabs Marengo format/async-invoke format.
@ -87,20 +115,27 @@ class TwelveLabsMarengoEmbeddingConfig:
- Video inputs (async-invoke only)
- Audio inputs (async-invoke only)
- S3 URLs for all media types (async-invoke only)
- Marengo 3.0 only: text_image and multi_input inputs (nested payload)
"""
# Get input_type or default to "text"
input_type: Final = cast(
TWELVELABS_EMBEDDING_INPUT_TYPES,
inference_params.get("inputType") or inference_params.get("input_type") or "text",
)
# Validate that async-invoke is used for video/audio
if input_type in ["video", "audio"] and not async_invoke_route:
raise ValueError(
f"Input type '{input_type}' requires async_invoke route. "
f"Use model format: 'bedrock/async_invoke/model_id'"
)
if self.is_marengo_3:
marengo_3_request: Final = build_marengo_3_request(input=input, inference_params=inference_params)
if async_invoke_route and model_id:
return self._wrap_async_invoke_request(
model_input=marengo_3_request, model_id=model_id, output_s3_uri=output_s3_uri
)
return marengo_3_request
transformed_request: Final[TwelveLabsMarengoEmbeddingRequest] = {"inputType": input_type}
if input_type == "text":
@ -154,7 +189,7 @@ class TwelveLabsMarengoEmbeddingConfig:
def _wrap_async_invoke_request(
self,
model_input: TwelveLabsMarengoEmbeddingRequest,
model_input: TwelveLabsMarengoEmbeddingRequest | TwelveLabsMarengo3EmbeddingRequest,
model_id: str,
output_s3_uri: str | None = None,
) -> TwelveLabsAsyncInvokeRequest:

View file

@ -690,6 +690,45 @@
"supports_embedding_image_input": true,
"supports_image_input": true
},
"twelvelabs.marengo-embed-3-0-v1:0": {
"input_cost_per_token": 7e-05,
"litellm_provider": "bedrock",
"max_input_tokens": 500,
"max_tokens": 500,
"mode": "embedding",
"output_cost_per_token": 0.0,
"output_vector_size": 512,
"supports_embedding_image_input": true,
"supports_image_input": true
},
"us.twelvelabs.marengo-embed-3-0-v1:0": {
"input_cost_per_token": 7e-05,
"input_cost_per_video_per_second": 0.0007,
"input_cost_per_audio_per_second": 0.00014,
"input_cost_per_image": 0.0001,
"litellm_provider": "bedrock",
"max_input_tokens": 500,
"max_tokens": 500,
"mode": "embedding",
"output_cost_per_token": 0.0,
"output_vector_size": 512,
"supports_embedding_image_input": true,
"supports_image_input": true
},
"eu.twelvelabs.marengo-embed-3-0-v1:0": {
"input_cost_per_token": 7e-05,
"input_cost_per_video_per_second": 0.0007,
"input_cost_per_audio_per_second": 0.00014,
"input_cost_per_image": 0.0001,
"litellm_provider": "bedrock",
"max_input_tokens": 500,
"max_tokens": 500,
"mode": "embedding",
"output_cost_per_token": 0.0,
"output_vector_size": 512,
"supports_embedding_image_input": true,
"supports_image_input": true
},
"twelvelabs.pegasus-1-2-v1:0": {
"input_cost_per_video_per_second": 0.00049,
"output_cost_per_token": 7.5e-06,

View file

@ -1,7 +1,7 @@
import json
from collections.abc import Sequence
from enum import Enum
from typing import TYPE_CHECKING, Any, Final, Literal
from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias
from typing_extensions import ReadOnly, Required, TypedDict, override
@ -557,7 +557,7 @@ class AmazonTitanMultimodalEmbeddingResponse(TypedDict):
message: str # Specifies any errors that occur during generation.
# TwelveLabs Marengo Embed 2.7 types
# TwelveLabs Marengo Embed types
TWELVELABS_EMBEDDING_INPUT_TYPES = Literal["text", "image", "video", "audio"]
TWELVELABS_EMBEDDING_OPTIONS = Literal["visual-text", "visual-image", "audio"]
@ -591,6 +591,113 @@ class TwelveLabsMarengoEmbeddingResponse(TypedDict):
endSec: float
TWELVELABS_MARENGO_3_INPUT_TYPES: TypeAlias = Literal["text", "image", "video", "audio", "text_image", "multi_input"]
TWELVELABS_MARENGO_3_EMBEDDING_OPTIONS: TypeAlias = Literal["visual", "audio", "transcription"]
TWELVELABS_MARENGO_3_EMBEDDING_TYPES: TypeAlias = Literal["separate_embedding", "fused_embedding"]
TWELVELABS_MARENGO_3_EMBEDDING_SCOPES: TypeAlias = Literal["clip", "asset"]
class TwelveLabsMarengo3FixedSegmentationConfig(TypedDict):
durationSec: ReadOnly[int]
class TwelveLabsMarengo3FixedSegmentation(TypedDict):
method: ReadOnly[Literal["fixed"]]
fixed: ReadOnly[TwelveLabsMarengo3FixedSegmentationConfig]
class TwelveLabsMarengo3DynamicSegmentationConfig(TypedDict):
minDurationSec: ReadOnly[int]
class TwelveLabsMarengo3DynamicSegmentation(TypedDict):
method: ReadOnly[Literal["dynamic"]]
dynamic: ReadOnly[TwelveLabsMarengo3DynamicSegmentationConfig]
TwelveLabsMarengo3Segmentation: TypeAlias = TwelveLabsMarengo3FixedSegmentation | TwelveLabsMarengo3DynamicSegmentation
class TwelveLabsMarengo3TextInput(TypedDict):
inputText: ReadOnly[str]
class TwelveLabsMarengo3ImageInput(TypedDict):
mediaSource: ReadOnly[TwelveLabsMediaSource]
class TwelveLabsMarengo3TimedMediaOptions(TypedDict, total=False):
startSec: ReadOnly[float]
endSec: ReadOnly[float]
segmentation: ReadOnly[TwelveLabsMarengo3Segmentation]
embeddingOption: ReadOnly[Sequence[TWELVELABS_MARENGO_3_EMBEDDING_OPTIONS]]
embeddingType: ReadOnly[Sequence[TWELVELABS_MARENGO_3_EMBEDDING_TYPES]]
embeddingScope: ReadOnly[Sequence[TWELVELABS_MARENGO_3_EMBEDDING_SCOPES]]
class TwelveLabsMarengo3TimedMediaInput(TwelveLabsMarengo3TimedMediaOptions):
mediaSource: Required[ReadOnly[TwelveLabsMediaSource]]
class TwelveLabsMarengo3TextImageInput(TypedDict):
inputText: ReadOnly[str]
mediaSource: ReadOnly[TwelveLabsMediaSource]
class TwelveLabsMarengo3NamedMediaSource(TwelveLabsMediaSource):
name: Required[ReadOnly[str]]
mediaType: Required[ReadOnly[Literal["image"]]]
class TwelveLabsMarengo3MultiInput(TypedDict, total=False):
inputText: ReadOnly[str]
mediaSources: Required[ReadOnly[Sequence[TwelveLabsMarengo3NamedMediaSource]]]
class TwelveLabsMarengo3RequestBase(TypedDict, total=False):
inferenceId: ReadOnly[str]
class TwelveLabsMarengo3TextRequest(TwelveLabsMarengo3RequestBase):
inputType: ReadOnly[Literal["text"]]
text: ReadOnly[TwelveLabsMarengo3TextInput]
class TwelveLabsMarengo3ImageRequest(TwelveLabsMarengo3RequestBase):
inputType: ReadOnly[Literal["image"]]
image: ReadOnly[TwelveLabsMarengo3ImageInput]
class TwelveLabsMarengo3VideoRequest(TwelveLabsMarengo3RequestBase):
inputType: ReadOnly[Literal["video"]]
video: ReadOnly[TwelveLabsMarengo3TimedMediaInput]
class TwelveLabsMarengo3AudioRequest(TwelveLabsMarengo3RequestBase):
inputType: ReadOnly[Literal["audio"]]
audio: ReadOnly[TwelveLabsMarengo3TimedMediaInput]
class TwelveLabsMarengo3TextImageRequest(TwelveLabsMarengo3RequestBase):
inputType: ReadOnly[Literal["text_image"]]
text_image: ReadOnly[TwelveLabsMarengo3TextImageInput]
class TwelveLabsMarengo3MultiInputRequest(TwelveLabsMarengo3RequestBase):
inputType: ReadOnly[Literal["multi_input"]]
multi_input: ReadOnly[TwelveLabsMarengo3MultiInput]
TwelveLabsMarengo3EmbeddingRequest: TypeAlias = (
TwelveLabsMarengo3TextRequest
| TwelveLabsMarengo3ImageRequest
| TwelveLabsMarengo3VideoRequest
| TwelveLabsMarengo3AudioRequest
| TwelveLabsMarengo3TextImageRequest
| TwelveLabsMarengo3MultiInputRequest
)
class TwelveLabsS3OutputDataConfig(TypedDict):
s3Uri: str
@ -601,7 +708,7 @@ class TwelveLabsOutputDataConfig(TypedDict):
class TwelveLabsAsyncInvokeRequest(TypedDict):
modelId: str
modelInput: TwelveLabsMarengoEmbeddingRequest
modelInput: ReadOnly[TwelveLabsMarengoEmbeddingRequest | TwelveLabsMarengo3EmbeddingRequest]
outputDataConfig: TwelveLabsOutputDataConfig

View file

@ -3623,7 +3623,7 @@ def get_optional_params_embeddings(
elif "cohere.embed" in model:
object = litellm.BedrockCohereEmbeddingConfig()
elif "twelvelabs" in model or "marengo" in model:
object = litellm.TwelveLabsMarengoEmbeddingConfig()
object = litellm.TwelveLabsMarengoEmbeddingConfig(model=model)
elif "nova" in model.lower():
object = litellm.AmazonNovaEmbeddingConfig()
else: # unmapped model

View file

@ -690,6 +690,45 @@
"supports_embedding_image_input": true,
"supports_image_input": true
},
"twelvelabs.marengo-embed-3-0-v1:0": {
"input_cost_per_token": 7e-05,
"litellm_provider": "bedrock",
"max_input_tokens": 500,
"max_tokens": 500,
"mode": "embedding",
"output_cost_per_token": 0.0,
"output_vector_size": 512,
"supports_embedding_image_input": true,
"supports_image_input": true
},
"us.twelvelabs.marengo-embed-3-0-v1:0": {
"input_cost_per_token": 7e-05,
"input_cost_per_video_per_second": 0.0007,
"input_cost_per_audio_per_second": 0.00014,
"input_cost_per_image": 0.0001,
"litellm_provider": "bedrock",
"max_input_tokens": 500,
"max_tokens": 500,
"mode": "embedding",
"output_cost_per_token": 0.0,
"output_vector_size": 512,
"supports_embedding_image_input": true,
"supports_image_input": true
},
"eu.twelvelabs.marengo-embed-3-0-v1:0": {
"input_cost_per_token": 7e-05,
"input_cost_per_video_per_second": 0.0007,
"input_cost_per_audio_per_second": 0.00014,
"input_cost_per_image": 0.0001,
"litellm_provider": "bedrock",
"max_input_tokens": 500,
"max_tokens": 500,
"mode": "embedding",
"output_cost_per_token": 0.0,
"output_vector_size": 512,
"supports_embedding_image_input": true,
"supports_image_input": true
},
"twelvelabs.pegasus-1-2-v1:0": {
"input_cost_per_video_per_second": 0.00049,
"output_cost_per_token": 7.5e-06,

View file

@ -184,6 +184,44 @@ class TestBedrockAsyncInvokeEmbedding:
request_url = mock_post.call_args.kwargs.get("url", "")
assert "/async-invoke" in request_url
def test_async_invoke_marengo_3_wraps_the_nested_payload_with_the_base_model_id(self):
client = HTTPHandler()
with patch.object(client, "post") as mock_post:
mock_response = Mock()
mock_response.status_code = 200
mock_response.text = json.dumps(async_invoke_response)
mock_response.json = lambda: json.loads(mock_response.text)
mock_post.return_value = mock_response
response = litellm.embedding(
model="bedrock/async_invoke/twelvelabs.marengo-embed-3-0-v1:0",
input="s3://test-bucket/clip.mp4",
client=client,
aws_region_name="us-east-1",
aws_bedrock_runtime_endpoint="https://bedrock-runtime.us-east-1.amazonaws.com",
api_key="test-bearer-token-12345",
input_type="video",
embeddingOption=["visual", "audio"],
segmentation={"method": "fixed", "fixed": {"durationSec": 6}},
output_s3_uri="s3://test-bucket/async-invoke-output/",
)
assert response._hidden_params._invocation_arn == async_invoke_response["invocationArn"]
assert mock_post.call_args.kwargs["url"].endswith("/async-invoke")
assert json.loads(mock_post.call_args.kwargs["data"]) == {
"modelId": "twelvelabs.marengo-embed-3-0-v1:0",
"modelInput": {
"inputType": "video",
"video": {
"mediaSource": {"s3Location": {"uri": "s3://test-bucket/clip.mp4"}},
"segmentation": {"method": "fixed", "fixed": {"durationSec": 6}},
"embeddingOption": ["visual", "audio"],
},
},
"outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://test-bucket/async-invoke-output/"}},
}
@pytest.mark.asyncio
async def test_async_invoke_twelvelabs_embedding_async_with_mock(self):
"""Test async invoke embedding with async calls."""

View file

@ -1059,3 +1059,132 @@ def test_bedrock_embedding_bearer_token_never_runs_the_sigv4_credential_chain(mo
assert response.data[0]["embedding"] == titan_embedding_response["embedding"]
assert mock_post.call_args.kwargs["headers"]["Authorization"] == "Bearer env-bearer-token-12345"
marengo_3_embedding_response = {"data": [{"embedding": [0.01 * i for i in range(512)]}]}
MARENGO_3_DUCK = "data:image/png;base64,ZHVjaw=="
@pytest.mark.parametrize(
"model,kwargs,expected_body",
[
(
"bedrock/us.twelvelabs.marengo-embed-3-0-v1:0",
{"input_type": "text"},
{"inputType": "text", "text": {"inputText": "a duck on water"}},
),
(
"bedrock/twelvelabs.marengo-embed-3-0-v1:0",
{"input_type": "text"},
{"inputType": "text", "text": {"inputText": "a duck on water"}},
),
(
"bedrock/us.twelvelabs.marengo-embed-3-0-v1:0",
{"input_type": "text_image", "media_source": MARENGO_3_DUCK},
{
"inputType": "text_image",
"text_image": {"inputText": "a duck on water", "mediaSource": {"base64String": "ZHVjaw=="}},
},
),
(
"bedrock/us.twelvelabs.marengo-embed-3-0-v1:0",
{"input_type": "multi_input", "media_sources": {"bird": MARENGO_3_DUCK}},
{
"inputType": "multi_input",
"multi_input": {
"inputText": "a duck on water",
"mediaSources": [{"name": "bird", "mediaType": "image", "base64String": "ZHVjaw=="}],
},
},
),
],
)
def test_marengo_3_embedding_sends_the_nested_payload_and_parses_512_dims(model, kwargs, expected_body):
client = HTTPHandler()
with patch.object(client, "post") as mock_post:
mock_response = Mock()
mock_response.status_code = 200
mock_response.text = json.dumps(marengo_3_embedding_response)
mock_response.json = lambda: json.loads(mock_response.text)
mock_post.return_value = mock_response
response = litellm.embedding(
model=model,
input="a duck on water",
client=client,
aws_region_name="us-east-1",
api_key="test-bearer-token-12345",
**kwargs,
)
assert json.loads(mock_post.call_args.kwargs["data"]) == expected_body
assert mock_post.call_args.kwargs["url"].endswith(f"/model/{model.removeprefix('bedrock/').replace(':', '%3A')}/invoke")
assert len(response.data[0]["embedding"]) == 512
assert response.data[0]["embedding"][:2] == [0.0, 0.01]
assert response.usage.prompt_tokens == 128
def test_marengo_3_image_embedding_sends_the_media_under_the_image_key():
client = HTTPHandler()
with patch.object(client, "post") as mock_post:
mock_response = Mock()
mock_response.status_code = 200
mock_response.text = json.dumps(marengo_3_embedding_response)
mock_response.json = lambda: json.loads(mock_response.text)
mock_post.return_value = mock_response
response = litellm.embedding(
model="bedrock/us.twelvelabs.marengo-embed-3-0-v1:0",
input=MARENGO_3_DUCK,
client=client,
aws_region_name="us-east-1",
api_key="test-bearer-token-12345",
input_type="image",
)
assert json.loads(mock_post.call_args.kwargs["data"]) == {
"inputType": "image",
"image": {"mediaSource": {"base64String": "ZHVjaw=="}},
}
assert len(response.data[0]["embedding"]) == 512
assert response.data[0]["embedding"][:2] == [0.0, 0.01]
def test_marengo_2_7_embedding_keeps_the_flat_payload():
client = HTTPHandler()
with patch.object(client, "post") as mock_post:
mock_response = Mock()
mock_response.status_code = 200
mock_response.text = json.dumps(twelvelabs_embedding_response)
mock_response.json = lambda: json.loads(mock_response.text)
mock_post.return_value = mock_response
response = litellm.embedding(
model="bedrock/us.twelvelabs.marengo-embed-2-7-v1:0",
input="a duck on water",
client=client,
aws_region_name="us-east-1",
api_key="test-bearer-token-12345",
input_type="text",
)
assert json.loads(mock_post.call_args.kwargs["data"]) == {
"inputType": "text",
"inputText": "a duck on water",
"textTruncate": "end",
}
assert response.data[0]["embedding"] == [0.1, 0.2, 0.3]
def test_marengo_3_text_image_without_media_source_is_a_bad_request():
with pytest.raises(litellm.BadRequestError, match=r"text_image.*media_source"):
litellm.embedding(
model="bedrock/us.twelvelabs.marengo-embed-3-0-v1:0",
input="a duck on water",
aws_region_name="us-east-1",
api_key="test-bearer-token-12345",
input_type="text_image",
)

View file

@ -0,0 +1,268 @@
import json
import pytest
from litellm.llms.bedrock.common_utils import BedrockError
from litellm.llms.bedrock.embed.twelvelabs_marengo_3_transformation import (
build_marengo_3_request,
is_marengo_3_model,
)
from litellm.llms.bedrock.embed.twelvelabs_marengo_transformation import (
TwelveLabsMarengoEmbeddingConfig,
)
MARENGO_3_BASE = "twelvelabs.marengo-embed-3-0-v1:0"
MARENGO_3_US = "us.twelvelabs.marengo-embed-3-0-v1:0"
MARENGO_27_US = "us.twelvelabs.marengo-embed-2-7-v1:0"
DUCK_DATA_URL = "data:image/png;base64,ZHVjaw=="
OUTPUT_S3_URI = "s3://out-bucket/marengo/"
@pytest.mark.parametrize(
"model,expected",
[
(MARENGO_3_BASE, True),
(MARENGO_3_US, True),
("eu.twelvelabs.marengo-embed-3-0-v1:0", True),
("async_invoke/twelvelabs.marengo-embed-3-0-v1:0", True),
(MARENGO_27_US, False),
("twelvelabs.marengo-embed-2-7-v1:0", False),
(None, False),
],
)
def test_is_marengo_3_model(model, expected):
assert is_marengo_3_model(model) is expected
def wire(request: object) -> object:
return json.loads(json.dumps(request))
def test_text_request_nests_input_text_under_text():
assert build_marengo_3_request("a dog on the beach", {"input_type": "text"}) == {
"inputType": "text",
"text": {"inputText": "a dog on the beach"},
}
def test_missing_input_type_defaults_to_text():
assert build_marengo_3_request("hello", {})["inputType"] == "text"
def test_camel_case_input_type_wins_over_snake_case():
request = build_marengo_3_request(DUCK_DATA_URL, {"inputType": "image", "input_type": "text"})
assert request["inputType"] == "image"
def test_image_request_strips_data_url_prefix():
assert build_marengo_3_request(DUCK_DATA_URL, {"input_type": "image"}) == {
"inputType": "image",
"image": {"mediaSource": {"base64String": "ZHVjaw=="}},
}
def test_image_request_from_s3_carries_bucket_owner():
request = build_marengo_3_request("s3://media/duck.png", {"input_type": "image", "bucketOwner": "123456789012"})
assert request == {
"inputType": "image",
"image": {"mediaSource": {"s3Location": {"uri": "s3://media/duck.png", "bucketOwner": "123456789012"}}},
}
def test_s3_media_without_bucket_owner_omits_the_key():
request = build_marengo_3_request("s3://media/duck.png", {"input_type": "image"})
assert request["image"]["mediaSource"] == {"s3Location": {"uri": "s3://media/duck.png"}}
def test_text_image_request_pairs_text_with_media_source():
request = build_marengo_3_request(
"a duck", {"input_type": "text_image", "media_source": DUCK_DATA_URL, "output_s3_uri": OUTPUT_S3_URI}
)
assert request == {
"inputType": "text_image",
"text_image": {"inputText": "a duck", "mediaSource": {"base64String": "ZHVjaw=="}},
}
def test_text_image_request_requires_media_source():
with pytest.raises(BedrockError, match=r"text_image.*media_source") as excinfo:
build_marengo_3_request("a duck", {"input_type": "text_image"})
assert excinfo.value.status_code == 400
def test_multi_input_request_names_each_media_source():
request = build_marengo_3_request(
"a photo of <@bird> next to <@dog>",
{
"input_type": "multi_input",
"media_sources": {"bird": DUCK_DATA_URL, "dog": "s3://media/dog.png"},
"bucketOwner": "123456789012",
},
)
assert wire(request) == {
"inputType": "multi_input",
"multi_input": {
"inputText": "a photo of <@bird> next to <@dog>",
"mediaSources": [
{"name": "bird", "mediaType": "image", "base64String": "ZHVjaw=="},
{
"name": "dog",
"mediaType": "image",
"s3Location": {"uri": "s3://media/dog.png", "bucketOwner": "123456789012"},
},
],
},
}
def test_multi_input_without_text_omits_input_text():
request = build_marengo_3_request("", {"input_type": "multi_input", "media_sources": {"bird": DUCK_DATA_URL}})
assert "inputText" not in request["multi_input"]
assert request["multi_input"]["mediaSources"][0]["name"] == "bird"
@pytest.mark.parametrize("params", [{"input_type": "multi_input"}, {"input_type": "multi_input", "media_sources": {}}])
def test_multi_input_request_requires_media_sources(params):
with pytest.raises(BedrockError, match=r"multi_input.*media_sources") as excinfo:
build_marengo_3_request("<@bird>", params)
assert excinfo.value.status_code == 400
@pytest.mark.parametrize("input_type", ["video", "audio"])
def test_timed_media_request_nests_every_option_under_the_media_key(input_type):
request = build_marengo_3_request(
"s3://media/clip.mp4",
{
"input_type": input_type,
"startSec": 2,
"endSec": 12.5,
"segmentation": {"method": "dynamic", "dynamic": {"minDurationSec": 4}},
"embeddingOption": ["visual", "audio"],
"embeddingType": ["fused_embedding"],
"embeddingScope": ["clip", "asset"],
"inferenceId": "req-42",
},
)
assert wire(request) == {
"inputType": input_type,
input_type: {
"mediaSource": {"s3Location": {"uri": "s3://media/clip.mp4"}},
"startSec": 2.0,
"endSec": 12.5,
"segmentation": {"method": "dynamic", "dynamic": {"minDurationSec": 4}},
"embeddingOption": ["visual", "audio"],
"embeddingType": ["fused_embedding"],
"embeddingScope": ["clip", "asset"],
},
"inferenceId": "req-42",
}
def test_timed_media_request_without_options_carries_only_the_media_source():
request = build_marengo_3_request("s3://media/clip.mp4", {"input_type": "video"})
assert request["video"] == {"mediaSource": {"s3Location": {"uri": "s3://media/clip.mp4"}}}
@pytest.mark.parametrize(
"params",
[
{"input_type": "clip"},
{"input_type": "video", "embeddingOption": ["visual-text"]},
{"input_type": "video", "segmentation": {"method": "fixed", "dynamic": {"minDurationSec": 4}}},
{"input_type": "multi_input", "media_sources": ["not", "a", "mapping"]},
],
)
def test_invalid_marengo_3_params_are_rejected_before_the_request_is_sent(params):
with pytest.raises(BedrockError, match=r"Invalid Marengo 3\.0 parameters") as excinfo:
build_marengo_3_request("s3://media/clip.mp4", params)
assert excinfo.value.status_code == 400
def test_config_sends_the_nested_payload_for_marengo_3_and_the_flat_one_for_2_7():
nested = TwelveLabsMarengoEmbeddingConfig(model=MARENGO_3_US)._transform_request(
input="hello", inference_params={"input_type": "text"}
)
flat = TwelveLabsMarengoEmbeddingConfig(model=MARENGO_27_US)._transform_request(
input="hello", inference_params={"input_type": "text"}
)
assert nested == {"inputType": "text", "text": {"inputText": "hello"}}
assert flat == {"inputType": "text", "inputText": "hello", "textTruncate": "end"}
def test_config_without_a_model_keeps_the_2_7_payload():
request = TwelveLabsMarengoEmbeddingConfig()._transform_request(input="hello", inference_params={})
assert request == {"inputType": "text", "inputText": "hello", "textTruncate": "end"}
@pytest.mark.parametrize("input_type", ["video", "audio"])
def test_marengo_3_video_and_audio_still_require_the_async_route(input_type):
with pytest.raises(ValueError, match=f"Input type '{input_type}' requires async_invoke route"):
TwelveLabsMarengoEmbeddingConfig(model=MARENGO_3_BASE)._transform_request(
input="s3://media/clip.mp4", inference_params={"input_type": input_type}
)
def test_marengo_3_async_invoke_wraps_the_nested_payload_with_the_base_model_id():
request = TwelveLabsMarengoEmbeddingConfig(model=MARENGO_3_BASE)._transform_request(
input="s3://media/clip.mp4",
inference_params={"input_type": "video", "embeddingOption": ["visual"], "output_s3_uri": OUTPUT_S3_URI},
async_invoke_route=True,
model_id="async_invoke%2Ftwelvelabs.marengo-embed-3-0-v1%3A0",
output_s3_uri=OUTPUT_S3_URI,
)
assert wire(request) == {
"modelId": MARENGO_3_BASE,
"modelInput": {
"inputType": "video",
"video": {"mediaSource": {"s3Location": {"uri": "s3://media/clip.mp4"}}, "embeddingOption": ["visual"]},
},
"outputDataConfig": {"s3OutputDataConfig": {"s3Uri": OUTPUT_S3_URI}},
}
def test_marengo_3_async_invoke_requires_an_output_s3_uri():
with pytest.raises(ValueError, match="output_s3_uri cannot be empty"):
TwelveLabsMarengoEmbeddingConfig(model=MARENGO_3_BASE)._transform_request(
input="hello",
inference_params={"input_type": "text"},
async_invoke_route=True,
model_id=MARENGO_3_BASE,
output_s3_uri="",
)
def test_encoding_format_float_no_longer_injects_2_7_embedding_options_for_marengo_3():
marengo_3 = TwelveLabsMarengoEmbeddingConfig(model=MARENGO_3_US).map_openai_params(
non_default_params={"encoding_format": "float"}, optional_params={}
)
marengo_27 = TwelveLabsMarengoEmbeddingConfig(model=MARENGO_27_US).map_openai_params(
non_default_params={"encoding_format": "float"}, optional_params={}
)
assert marengo_3 == {}
assert marengo_27 == {"embeddingOption": ["visual-text", "visual-image"]}
def test_marengo_3_only_params_are_forwarded_by_map_openai_params():
mapped = TwelveLabsMarengoEmbeddingConfig(model=MARENGO_3_US).map_openai_params(
non_default_params={
"input_type": "text_image",
"media_source": DUCK_DATA_URL,
"media_sources": {"bird": DUCK_DATA_URL},
"endSec": 5,
"segmentation": {"method": "fixed", "fixed": {"durationSec": 6}},
"embeddingType": ["separate_embedding"],
"embeddingScope": ["clip"],
"inferenceId": "req-1",
},
optional_params={},
)
assert mapped == {
"inputType": "text_image",
"media_source": DUCK_DATA_URL,
"media_sources": {"bird": DUCK_DATA_URL},
"endSec": 5,
"segmentation": {"method": "fixed", "fixed": {"durationSec": 6}},
"embeddingType": ["separate_embedding"],
"embeddingScope": ["clip"],
"inferenceId": "req-1",
}

View file

@ -0,0 +1,88 @@
import json
from pathlib import Path
import pytest
import litellm
from litellm.constants import bedrock_embedding_models
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
from litellm.types.utils import Usage
REPO_ROOT = Path(__file__).parents[2]
MAIN_PATH = REPO_ROOT / "model_prices_and_context_window.json"
BACKUP_PATH = REPO_ROOT / "litellm" / "model_prices_and_context_window_backup.json"
BASE_MODEL = "twelvelabs.marengo-embed-3-0-v1:0"
PROFILE_MODELS = ("us.twelvelabs.marengo-embed-3-0-v1:0", "eu.twelvelabs.marengo-embed-3-0-v1:0")
ALL_MODELS = (BASE_MODEL, *PROFILE_MODELS)
TEXT_REQUEST_COST = 7e-05
IMAGE_REQUEST_COST = 0.0001
VIDEO_COST_PER_SECOND = 0.0007
AUDIO_COST_PER_SECOND = 0.00014
def _load(path):
with open(path) as f:
return json.load(f)
@pytest.mark.parametrize("model", ALL_MODELS)
def test_marengo_embed_3_specs(model):
info = _load(MAIN_PATH).get(model)
assert info is not None, f"{model} missing from model_prices_and_context_window.json"
assert info["litellm_provider"] == "bedrock"
assert info["mode"] == "embedding"
assert info["input_cost_per_token"] == TEXT_REQUEST_COST
assert info["output_cost_per_token"] == 0.0
assert info["max_input_tokens"] == 500
assert info["max_tokens"] == 500
assert info["output_vector_size"] == 512
assert info["supports_embedding_image_input"] is True
assert info["supports_image_input"] is True
assert "deprecation_date" not in info
routed_model, provider, _, _ = get_llm_provider(model=f"bedrock/{model}")
assert routed_model == model
assert provider == "bedrock"
@pytest.mark.parametrize("model", PROFILE_MODELS)
def test_marengo_embed_3_inference_profiles_price_image_video_and_audio(model):
info = _load(MAIN_PATH)[model]
assert info["input_cost_per_image"] == IMAGE_REQUEST_COST
assert info["input_cost_per_video_per_second"] == VIDEO_COST_PER_SECOND
assert info["input_cost_per_audio_per_second"] == AUDIO_COST_PER_SECOND
@pytest.mark.parametrize("model", ALL_MODELS)
def test_marengo_embed_3_is_visible_to_callers(model, local_model_cost_map):
info = litellm.get_model_info(model=model, custom_llm_provider="bedrock")
assert info["mode"] == "embedding"
assert info["output_vector_size"] == 512
assert info["max_input_tokens"] == 500
@pytest.mark.parametrize("model", ALL_MODELS)
def test_marengo_embed_3_text_request_is_billed(model, local_model_cost_map):
usage = Usage(prompt_tokens=128, completion_tokens=0, total_tokens=128)
prompt_cost, completion_cost = litellm.cost_per_token(
model=model, usage_object=usage, custom_llm_provider="bedrock"
)
assert prompt_cost == pytest.approx(128 * TEXT_REQUEST_COST)
assert completion_cost == 0.0
def test_marengo_embed_3_is_a_known_bedrock_embedding_model():
assert BASE_MODEL in bedrock_embedding_models
@pytest.mark.parametrize("model", ALL_MODELS)
def test_backup_matches_main(model):
main_cost = _load(MAIN_PATH)
backup_cost = _load(BACKUP_PATH)
assert model in main_cost, f"{model} missing from model_prices_and_context_window.json"
assert model in backup_cost, f"{model} missing from model_prices_and_context_window_backup.json"
assert backup_cost[model] == main_cost[model], f"{model} differs between main and backup model cost maps"