fix(bedrock): reject Marengo 2.7-only and misplaced media params on 3.0 unless drop_params

Marengo 3.0 requests now get a 400 naming any textTruncate, lengthSec,
useFixedLengthSec, or minClipSec parameter, and any video or audio option
sent with a text, image, text_image, or multi_input request, instead of
silently dropping them. drop_params (global, per deployment, or per
request) drops them instead. Pydantic validation errors name the field
and the reason, and the 3.0 marker is the exact "marengo-embed-3-" model
id segment.
This commit is contained in:
mateo-berri 2026-09-07 18:40:31 -07:00
parent 86790a7723
commit 80fea089b6
4 changed files with 158 additions and 25 deletions

View file

@ -35,7 +35,7 @@ from .amazon_titan_multimodal_transformation import (
)
from .amazon_titan_v2_transformation import AmazonTitanV2Config
from .cohere_transformation import BedrockCohereEmbeddingConfig
from .twelvelabs_marengo_transformation import TwelveLabsMarengoEmbeddingConfig
from .twelvelabs_marengo_transformation import TwelveLabsMarengoEmbeddingConfig, drop_params_enabled
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
@ -480,6 +480,7 @@ class BedrockEmbedding(BaseAWSLLM):
async_invoke_route=has_async_invoke,
model_id=modelId,
output_s3_uri=inference_params.get("output_s3_uri"),
drop_params=drop_params_enabled(litellm_params),
)
batch_data.append(twelvelabs_request)
elif provider == "nova":

View file

@ -35,7 +35,7 @@ from litellm.types.llms.bedrock import (
)
from litellm.utils import get_base64_str
MARENGO_3_MODEL_MARKER: Final = "marengo-embed-3"
MARENGO_3_MODEL_MARKER: Final = "marengo-embed-3-"
S3_URI_PREFIX: Final = "s3://"
TIMED_MEDIA_OPTION_FIELDS: Final = MappingProxyType(
{
@ -48,6 +48,9 @@ TIMED_MEDIA_OPTION_FIELDS: Final = MappingProxyType(
}
)
TIMED_MEDIA_OPTIONS: Final = TypeAdapter(TwelveLabsMarengo3TimedMediaOptions)
TIMED_INPUT_TYPES: Final = frozenset({"video", "audio"})
MARENGO_2_7_ONLY_PARAMS: Final = ("textTruncate", "lengthSec", "useFixedLengthSec", "minClipSec")
MARENGO_2_7_ONLY_FIELDS: Final = MappingProxyType({name: True for name in MARENGO_2_7_ONLY_PARAMS})
def is_marengo_3_model(model: str | None) -> bool:
@ -69,15 +72,23 @@ class Marengo3Params(BaseModel):
embeddingType: tuple[TWELVELABS_MARENGO_3_EMBEDDING_TYPES, ...] | None = None
embeddingScope: tuple[TWELVELABS_MARENGO_3_EMBEDDING_SCOPES, ...] | None = None
inferenceId: str | None = None
textTruncate: object = None
lengthSec: object = None
useFixedLengthSec: object = None
minClipSec: object = 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)
)
return TIMED_MEDIA_OPTIONS.validate_python(self.given_timed_media_options())
def given_timed_media_options(self) -> dict[str, object]:
return self.model_dump(include=TIMED_MEDIA_OPTION_FIELDS, exclude_none=True)
def given_2_7_only_params(self) -> dict[str, object]:
return self.model_dump(include=MARENGO_2_7_ONLY_FIELDS, exclude_none=True)
def _s3_location(uri: str, bucket_owner: str | None) -> TwelveLabsS3Location:
@ -113,11 +124,23 @@ def _timed_media_input(media: str, params: Marengo3Params) -> TwelveLabsMarengo3
return timed
def _describe(error: ValidationError) -> str:
return "; ".join(
f"{'.'.join(str(part) for part in problem['loc'])}: {problem['msg']}" for problem in error.errors()
)
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
raise BedrockError(status_code=400, message=f"Invalid Marengo 3.0 parameters: {_describe(error)}") from error
def _reject_unless_dropped(given: Mapping[str, object], drop_params: bool, reason: str) -> None:
if not given or drop_params:
return
raise BedrockError(status_code=400, message=f"{reason} {', '.join(given)}; set drop_params to drop them")
def _require(value: str | None, input_type: str, param_name: str) -> str:
@ -143,10 +166,19 @@ def _request_base(inference_id: str | None) -> TwelveLabsMarengo3RequestBase:
return identified
def build_marengo_3_request(input: str, inference_params: Mapping[str, object]) -> TwelveLabsMarengo3EmbeddingRequest:
def build_marengo_3_request(
input: str, inference_params: Mapping[str, object], drop_params: bool = False
) -> TwelveLabsMarengo3EmbeddingRequest:
params: Final = _validated_params(inference_params)
base: Final = _request_base(params.inferenceId)
input_type: Final = params.resolved_input_type
_reject_unless_dropped(
params.given_2_7_only_params(), drop_params, "Marengo 3.0 does not accept the Marengo 2.7 parameters"
)
if input_type not in TIMED_INPUT_TYPES:
_reject_unless_dropped(
params.given_timed_media_options(), drop_params, f"Input type '{input_type}' does not accept"
)
match input_type:
case "text":
text_request: Final[TwelveLabsMarengo3TextRequest] = {

View file

@ -13,7 +13,9 @@ from typing import Final, cast
from pydantic import BaseModel, ConfigDict, TypeAdapter
from typing_extensions import assert_never
import litellm
from litellm.llms.bedrock.embed.twelvelabs_marengo_3_transformation import (
MARENGO_2_7_ONLY_PARAMS,
build_marengo_3_request,
is_marengo_3_model,
)
@ -99,6 +101,25 @@ def _billed_usage(batch_data: list[dict] | None) -> Usage:
return Usage(prompt_tokens=0, completion_tokens=0, total_tokens=0, prompt_tokens_details=details)
MARENGO_SHARED_PARAMS: Final = (
"encoding_format",
"embeddingOption",
"startSec",
"input_type",
"endSec",
"segmentation",
"embeddingType",
"embeddingScope",
"inferenceId",
"media_source",
"media_sources",
)
def drop_params_enabled(litellm_params: Mapping[str, object]) -> bool:
return litellm.drop_params is True or litellm_params.get("drop_params") is True
class TwelveLabsMarengoEmbeddingConfig:
"""
Reference - https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-marengo.html
@ -115,23 +136,9 @@ class TwelveLabsMarengoEmbeddingConfig:
self.is_marengo_3: Final = is_marengo_3_model(model)
def get_supported_openai_params(self) -> list[str]:
return [
"encoding_format",
"textTruncate",
"embeddingOption",
"startSec",
"lengthSec",
"useFixedLengthSec",
"minClipSec",
"input_type",
"endSec",
"segmentation",
"embeddingType",
"embeddingScope",
"inferenceId",
"media_source",
"media_sources",
]
if self.is_marengo_3:
return list(MARENGO_SHARED_PARAMS)
return [*MARENGO_SHARED_PARAMS, *MARENGO_2_7_ONLY_PARAMS]
def map_openai_params(self, non_default_params: dict, optional_params: dict) -> dict:
for k, v in non_default_params.items():
@ -179,6 +186,7 @@ class TwelveLabsMarengoEmbeddingConfig:
async_invoke_route: bool = False,
model_id: str | None = None,
output_s3_uri: str | None = None,
drop_params: bool = False,
) -> TwelveLabsMarengoEmbeddingRequest | TwelveLabsMarengo3EmbeddingRequest | TwelveLabsAsyncInvokeRequest:
"""
Transform OpenAI-style input to TwelveLabs Marengo format/async-invoke format.
@ -203,7 +211,9 @@ class TwelveLabsMarengoEmbeddingConfig:
)
if self.is_marengo_3:
marengo_3_request: Final = build_marengo_3_request(input=input, inference_params=inference_params)
marengo_3_request: Final = build_marengo_3_request(
input=input, inference_params=inference_params, drop_params=drop_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

View file

@ -2,13 +2,16 @@ import json
import pytest
import litellm
from litellm.llms.bedrock.common_utils import BedrockError
from litellm.llms.bedrock.embed.twelvelabs_marengo_3_transformation import (
MARENGO_2_7_ONLY_PARAMS,
build_marengo_3_request,
is_marengo_3_model,
)
from litellm.llms.bedrock.embed.twelvelabs_marengo_transformation import (
TwelveLabsMarengoEmbeddingConfig,
drop_params_enabled,
)
MARENGO_3_BASE = "twelvelabs.marengo-embed-3-0-v1:0"
@ -27,6 +30,7 @@ OUTPUT_S3_URI = "s3://out-bucket/marengo/"
("async_invoke/twelvelabs.marengo-embed-3-0-v1:0", True),
(MARENGO_27_US, False),
("twelvelabs.marengo-embed-2-7-v1:0", False),
("twelvelabs.marengo-embed-30-v1:0", False),
(None, False),
],
)
@ -266,3 +270,89 @@ def test_marengo_3_only_params_are_forwarded_by_map_openai_params():
"embeddingScope": ["clip"],
"inferenceId": "req-1",
}
@pytest.mark.parametrize(
"params,problem",
[
(
{"input_type": "clip"},
"input_type: Input should be 'text', 'image', 'video', 'audio', 'text_image' or 'multi_input'",
),
({"input_type": "video", "embeddingOption": "visual"}, "embeddingOption: Input should be a valid tuple"),
(
{"input_type": "multi_input", "media_sources": ["not", "a", "mapping"]},
"media_sources: Input should be a valid dictionary",
),
],
)
def test_invalid_marengo_3_params_name_the_field_and_the_reason(params, problem):
with pytest.raises(BedrockError) as excinfo:
build_marengo_3_request("s3://media/clip.mp4", params)
assert excinfo.value.message == f"Invalid Marengo 3.0 parameters: {problem}"
MARENGO_2_7_ONLY_VALUES = {"textTruncate": "end", "lengthSec": 5, "useFixedLengthSec": True, "minClipSec": 2}
@pytest.mark.parametrize("name", MARENGO_2_7_ONLY_PARAMS)
def test_marengo_2_7_only_params_are_rejected_on_3_0_unless_dropped(name):
params = {"input_type": "text", name: MARENGO_2_7_ONLY_VALUES[name]}
with pytest.raises(BedrockError) as excinfo:
build_marengo_3_request("hello", params)
assert excinfo.value.status_code == 400
assert excinfo.value.message == (
f"Marengo 3.0 does not accept the Marengo 2.7 parameters {name}; set drop_params to drop them"
)
assert build_marengo_3_request("hello", params, drop_params=True) == {
"inputType": "text",
"text": {"inputText": "hello"},
}
def test_marengo_2_7_only_params_are_advertised_only_for_2_7():
marengo_3 = TwelveLabsMarengoEmbeddingConfig(model=MARENGO_3_US).get_supported_openai_params()
marengo_27 = TwelveLabsMarengoEmbeddingConfig(model=MARENGO_27_US).get_supported_openai_params()
assert set(MARENGO_2_7_ONLY_PARAMS).isdisjoint(marengo_3)
assert set(MARENGO_2_7_ONLY_PARAMS) <= set(marengo_27)
assert set(marengo_3) <= set(marengo_27)
def test_drop_params_comes_from_the_call_or_the_global(monkeypatch):
monkeypatch.setattr(litellm, "drop_params", False)
assert drop_params_enabled({}) is False
assert drop_params_enabled({"drop_params": True}) is True
monkeypatch.setattr(litellm, "drop_params", True)
assert drop_params_enabled({}) is True
def test_config_drops_marengo_2_7_only_params_only_when_asked():
config = TwelveLabsMarengoEmbeddingConfig(model=MARENGO_3_US)
with pytest.raises(BedrockError, match=r"Marengo 2\.7 parameters textTruncate"):
config._transform_request("hello", {"textTruncate": "end"})
assert config._transform_request("hello", {"textTruncate": "end"}, drop_params=True) == {
"inputType": "text",
"text": {"inputText": "hello"},
}
@pytest.mark.parametrize(
"params",
[
{"input_type": "text"},
{"input_type": "image"},
{"input_type": "text_image", "media_source": DUCK_DATA_URL},
{"input_type": "multi_input", "media_sources": {"bird": DUCK_DATA_URL}},
],
)
def test_timed_media_options_are_rejected_on_untimed_input_types_unless_dropped(params):
timed = {**params, "startSec": 0, "embeddingOption": ["visual"]}
with pytest.raises(BedrockError) as excinfo:
build_marengo_3_request(DUCK_DATA_URL, timed)
assert excinfo.value.status_code == 400
assert excinfo.value.message == (
f"Input type '{params['input_type']}' does not accept startSec, embeddingOption; set drop_params to drop them"
)
assert build_marengo_3_request(DUCK_DATA_URL, timed, drop_params=True) == build_marengo_3_request(
DUCK_DATA_URL, params
)