fix(bedrock): make combined text+image embedding opt-in via combine_text_image_pairs flag

fix(bedrock): make combined text+image embedding opt-in via `combine_text_image_pairs` flag
This commit is contained in:
Serhii Myronenko 2026-04-14 16:50:34 +02:00 committed by GitHub
commit c7c5a61860
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 226 additions and 52 deletions

View file

@ -162,7 +162,9 @@ curl -X POST 'http://0.0.0.0:4000/v1/embeddings' \
Bedrock multimodal embedding models (`amazon.titan-embed-image-v1` and `amazon.nova-2-multimodal-embeddings-v1:0`) support sending both text and image together in a single embedding request.
Pass the text string **immediately before** its paired image in the `input` list. LiteLLM will detect the adjacent `[text, base64_image]` pair and merge them into a single Bedrock request containing both `inputText` and `inputImage` (Titan) or both `text` and `image` (Nova).
Pass the text string **immediately before** its paired image in the `input` list and set `combine_text_image_pairs=True`. LiteLLM will detect the adjacent `[text, base64_image]` pair and merge them into a single Bedrock request containing both `inputText` and `inputImage` (Titan) or both `text` and `image` (Nova).
When `combine_text_image_pairs` is omitted or `False` (the default), each element in `input` is sent as a separate request — preserving backward compatibility for existing code.
<Tabs>
<TabItem value="sdk" label="SDK">
@ -181,6 +183,7 @@ response = embedding(
"Red leather handbag with gold buckle", # text
f"data:image/jpeg;base64,{image_b64}", # image (immediately after text)
],
combine_text_image_pairs=True,
dimensions=1024,
aws_region_name="us-east-1",
)
@ -192,6 +195,7 @@ response = embedding(
"shoes photo", # text
f"data:image/jpeg;base64,{image_b64}", # image (immediately after text)
],
combine_text_image_pairs=True,
aws_region_name="us-east-1",
)
```
@ -220,6 +224,7 @@ model_list:
litellm_params:
model: bedrock/amazon.titan-embed-image-v1
aws_region_name: us-east-1
combine_text_image_pairs: true
```
2. Start proxy

View file

@ -24,7 +24,6 @@ from litellm.types.llms.bedrock import (
CohereEmbeddingRequest,
)
from litellm.types.utils import EmbeddingResponse, LlmProviders
from litellm.utils import is_base64_encoded
from ..base_aws_llm import BaseAWSLLM
from ..common_utils import BedrockError
@ -437,6 +436,9 @@ class BedrockEmbedding(BaseAWSLLM):
inference_params.pop(
"user", None
) # make sure user is not passed in for bedrock call
combine_text_image_pairs: bool = inference_params.pop(
"combine_text_image_pairs", False
)
data: Optional[CohereEmbeddingRequest] = None
batch_data: Optional[List] = None
@ -451,35 +453,46 @@ class BedrockEmbedding(BaseAWSLLM):
]:
batch_data = []
if model == "amazon.titan-embed-image-v1":
# Scan for adjacent [text, base64_image] pairs and combine them
idx = 0
while idx < len(input):
current = input[idx]
next_elem = input[idx + 1] if idx + 1 < len(input) else None
if (
isinstance(current, str)
and not is_base64_encoded(current)
and next_elem is not None
and is_base64_encoded(next_elem)
):
# Text followed by image → combined request
transformed_request: (
AmazonEmbeddingRequest
) = AmazonTitanMultimodalEmbeddingG1Config()._transform_request(
input=next_elem,
inference_params=inference_params,
input_text=current,
)
batch_data.append(transformed_request)
idx += 2
else:
if combine_text_image_pairs:
# Scan for adjacent [text, data:image/...] pairs and combine them
idx = 0
while idx < len(input):
current = input[idx]
next_elem = input[idx + 1] if idx + 1 < len(input) else None
if (
isinstance(current, str)
and not current.startswith("data:")
and next_elem is not None
and isinstance(next_elem, str)
and next_elem.startswith("data:")
):
# Text followed by image → combined request
transformed_request: (
AmazonEmbeddingRequest
) = AmazonTitanMultimodalEmbeddingG1Config()._transform_request(
input=next_elem,
inference_params=inference_params,
input_text=current,
)
batch_data.append(transformed_request)
idx += 2
else:
transformed_request = (
AmazonTitanMultimodalEmbeddingG1Config()._transform_request(
input=current, inference_params=inference_params
)
)
batch_data.append(transformed_request)
idx += 1
else:
# Default: process each element independently (backward-compatible)
for i in input:
transformed_request = (
AmazonTitanMultimodalEmbeddingG1Config()._transform_request(
input=current, inference_params=inference_params
input=i, inference_params=inference_params
)
)
batch_data.append(transformed_request)
idx += 1
else:
for i in input:
if model == "amazon.titan-embed-text-v1":
@ -516,39 +529,51 @@ class BedrockEmbedding(BaseAWSLLM):
batch_data.append(twelvelabs_request)
elif provider == "nova":
batch_data = []
# Scan for adjacent [text, data:image/...] pairs and combine them
idx = 0
while idx < len(input):
current = input[idx]
next_elem = input[idx + 1] if idx + 1 < len(input) else None
if (
isinstance(current, str)
and not current.startswith("data:")
and next_elem is not None
and isinstance(next_elem, str)
and next_elem.startswith("data:image/")
):
# Text followed by image → combined request
if combine_text_image_pairs:
# Scan for adjacent [text, data:image/...] pairs and combine them
idx = 0
while idx < len(input):
current = input[idx]
next_elem = input[idx + 1] if idx + 1 < len(input) else None
if (
isinstance(current, str)
and not current.startswith("data:")
and next_elem is not None
and isinstance(next_elem, str)
and next_elem.startswith("data:image/")
):
# Text followed by image → combined request
nova_request = AmazonNovaEmbeddingConfig()._transform_request(
input=next_elem,
inference_params=inference_params,
async_invoke_route=has_async_invoke,
model_id=modelId,
output_s3_uri=inference_params.get("output_s3_uri"),
input_text=current,
)
batch_data.append(nova_request)
idx += 2
else:
nova_request = AmazonNovaEmbeddingConfig()._transform_request(
input=current,
inference_params=inference_params,
async_invoke_route=has_async_invoke,
model_id=modelId,
output_s3_uri=inference_params.get("output_s3_uri"),
)
batch_data.append(nova_request)
idx += 1
else:
# Default: process each element independently (backward-compatible)
for i in input:
nova_request = AmazonNovaEmbeddingConfig()._transform_request(
input=next_elem,
inference_params=inference_params,
async_invoke_route=has_async_invoke,
model_id=modelId,
output_s3_uri=inference_params.get("output_s3_uri"),
input_text=current,
)
batch_data.append(nova_request)
idx += 2
else:
nova_request = AmazonNovaEmbeddingConfig()._transform_request(
input=current,
input=i,
inference_params=inference_params,
async_invoke_route=has_async_invoke,
model_id=modelId,
output_s3_uri=inference_params.get("output_s3_uri"),
)
batch_data.append(nova_request)
idx += 1
### SET RUNTIME ENDPOINT ###
endpoint_url, proxy_endpoint_url = self.get_runtime_endpoint(

View file

@ -1021,6 +1021,7 @@ def test_titan_multimodal_combined_text_and_image_request():
aws_access_key_id='fake',
aws_secret_access_key='fake',
aws_region_name='us-east-1',
combine_text_image_pairs=True,
)
assert isinstance(response, litellm.EmbeddingResponse)
@ -1140,6 +1141,7 @@ def test_nova_combined_text_and_image_http_request():
aws_access_key_id='fake',
aws_secret_access_key='fake',
aws_region_name='us-east-1',
combine_text_image_pairs=True,
)
assert isinstance(response, litellm.EmbeddingResponse)
@ -1151,3 +1153,145 @@ def test_nova_combined_text_and_image_http_request():
assert 'image' in params
assert 'text' in params
assert params['text']['value'] == 'shoes photo'
# ──────────────────────────────────────────────────────────────────────────────
# combine_text_image_pairs opt-in flag tests
# ──────────────────────────────────────────────────────────────────────────────
def test_titan_multimodal_no_combine_flag_returns_separate():
"""Without combine_text_image_pairs, [text, image] must produce 2 separate HTTP calls (backward compat)."""
client = HTTPHandler()
embed_response = {
'embedding': [0.1, 0.2, 0.3],
'inputTextTokenCount': 5,
}
with patch.object(client, 'post') as mock_post:
mock_response = Mock()
mock_response.status_code = 200
mock_response.text = json.dumps(embed_response)
mock_response.json = lambda: json.loads(mock_response.text)
mock_post.return_value = mock_response
response = litellm.embedding(
model='bedrock/amazon.titan-embed-image-v1',
input=[
'Red leather handbag',
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==',
],
client=client,
aws_access_key_id='fake',
aws_secret_access_key='fake',
aws_region_name='us-east-1',
# NOTE: combine_text_image_pairs NOT passed — default False
)
assert isinstance(response, litellm.EmbeddingResponse)
# Each element processed independently → 2 separate HTTP calls
assert mock_post.call_count == 2
def test_titan_multimodal_combine_flag_returns_fused():
"""With combine_text_image_pairs=True, [text, image] must produce 1 fused HTTP call."""
client = HTTPHandler()
embed_response = {
'embedding': [0.1, 0.2, 0.3],
'inputTextTokenCount': 5,
}
with patch.object(client, 'post') as mock_post:
mock_response = Mock()
mock_response.status_code = 200
mock_response.text = json.dumps(embed_response)
mock_response.json = lambda: json.loads(mock_response.text)
mock_post.return_value = mock_response
response = litellm.embedding(
model='bedrock/amazon.titan-embed-image-v1',
input=[
'Red leather handbag',
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==',
],
client=client,
aws_access_key_id='fake',
aws_secret_access_key='fake',
aws_region_name='us-east-1',
combine_text_image_pairs=True,
)
assert isinstance(response, litellm.EmbeddingResponse)
assert mock_post.call_count == 1
request_body = json.loads(mock_post.call_args.kwargs.get('data', '{}'))
assert 'inputText' in request_body
assert 'inputImage' in request_body
def test_nova_text_only_standalone():
"""Nova with a single text input (no pairing) produces 1 call with only text in singleEmbeddingParams."""
client = HTTPHandler()
nova_response = {
'embeddings': [
{'embeddingType': 'TEXT', 'embedding': [0.1, 0.2, 0.3]},
]
}
with patch.object(client, 'post') as mock_post:
mock_response = Mock()
mock_response.status_code = 200
mock_response.text = json.dumps(nova_response)
mock_response.json = lambda: json.loads(mock_response.text)
mock_post.return_value = mock_response
response = litellm.embedding(
model='bedrock/amazon.nova-2-multimodal-embeddings-v1:0',
input=['just some text'],
client=client,
aws_access_key_id='fake',
aws_secret_access_key='fake',
aws_region_name='us-east-1',
)
assert isinstance(response, litellm.EmbeddingResponse)
assert mock_post.call_count == 1
request_body = json.loads(mock_post.call_args.kwargs.get('data', '{}'))
params = request_body.get('singleEmbeddingParams', {})
assert 'text' in params
assert 'image' not in params
def test_nova_image_only_standalone():
"""Nova with a single data:image/... input (no pairing) produces 1 call with only image in singleEmbeddingParams."""
client = HTTPHandler()
nova_response = {
'embeddings': [
{'embeddingType': 'IMAGE', 'embedding': [0.1, 0.2, 0.3]},
]
}
with patch.object(client, 'post') as mock_post:
mock_response = Mock()
mock_response.status_code = 200
mock_response.text = json.dumps(nova_response)
mock_response.json = lambda: json.loads(mock_response.text)
mock_post.return_value = mock_response
response = litellm.embedding(
model='bedrock/amazon.nova-2-multimodal-embeddings-v1:0',
input=['data:image/jpeg;base64,/9j/4AAQSkZJRgAB'],
client=client,
aws_access_key_id='fake',
aws_secret_access_key='fake',
aws_region_name='us-east-1',
)
assert isinstance(response, litellm.EmbeddingResponse)
assert mock_post.call_count == 1
request_body = json.loads(mock_post.call_args.kwargs.get('data', '{}'))
params = request_body.get('singleEmbeddingParams', {})
assert 'image' in params
assert 'text' not in params