fix(bedrock): return a 400 for malformed batch JSONL lines (#45992)

* fix(bedrock): return a 400 for malformed batch JSONL lines

A malformed line in a Bedrock batch input file escaped as a JSONDecodeError
from the upload and file content conversions, which the proxy reported as a
500. Parse those files line by line and raise a 400 that names the bad line.
Object name inference keeps its fallback for files that are not JSONL.

Fixes #45060

* test(bedrock): cover blank lines in malformed batch JSONL

* fix(bedrock): reject a malformed batch line whatever the upload's MIME type

The upload only converts, and so re-checks, batch files whose MIME type is on the
allowlist. Falling back to the generic object name let a malformed file sent as
application/x-jsonl skip both checks and land in S3 unchanged, so the object-name
step now runs the same strict per-line parse and raises the 400.

* fix(bedrock): reject every malformed batch input line with a 400 naming the line

Non-object lines, a missing or non-object body, a non-string body.model,
non-UTF-8 bytes, nesting too deep to parse, and rows the Bedrock mapping
cannot convert escaped as AttributeError, TypeError, UnicodeDecodeError or
RecursionError and surfaced as 500s. They now raise a 400 BedrockError in
the same "Batch input file line N ..." wording the proxy pre-check and the
Vertex adapter use. Object naming reads only the first line and never
raises, and a UTF-8 byte order mark is accepted.

* docs(bedrock): point the batch file name comment at the helper that builds it

* test(bedrock): mark new batch test locals Final and give the line builders explicit params

* test(bedrock): use single quotes inside the batch-line f-strings so the file parses on Python 3.10 and 3.11

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Co-authored-by: ryan-crabbe-berri <ryan@berri.ai>
This commit is contained in:
Abhishek Acharya 2026-10-21 00:43:43 +05:45 • committed by GitHub
parent 81d8c9af38
commit 7655c2c839
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 364 additions and 110 deletions

View file

@ -42,7 +42,7 @@ if TYPE_CHECKING:
# Bedrock batch input files are uploaded as
# s3://bucket/litellm-bedrock-files-{model, ":" -> "-"}-{uuid4}.jsonl (see
# BedrockFilesTransformation._get_s3_object_name). A uuid4 is always 36 hex/dash
# _batch_s3_object_name in litellm/llms/bedrock/files/transformation.py). A uuid4 is always 36 hex/dash
# characters, so it can be stripped off the end unambiguously even though the
# model name itself may contain dashes.
_S3_BATCH_FILE_UUID_SUFFIX_PATTERN: Final = re.compile(

View file

@ -4,7 +4,7 @@ import os
import posixpath
import time
import xml.etree.ElementTree as ET
from collections.abc import Iterable, Mapping, MutableMapping, Sequence
from collections.abc import Iterable, Iterator, Mapping, MutableMapping, Sequence
from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime
@ -23,6 +23,7 @@ from typing_extensions import ReadOnly
from litellm._logging import verbose_logger
from litellm._uuid import uuid
from litellm.constants import BEDROCK_INVOKE_PROVIDERS_LITERAL
from litellm.exceptions import BadRequestError
from litellm.files.utils import FilesAPIUtils
from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix
from litellm.litellm_core_utils.cloud_storage_security import (
@ -324,7 +325,10 @@ _REJECTED_FILE_ID_REQUEST_URL: Final = "https://litellm.ai"
def _rejected_file_id(reason: ValueError) -> BedrockError:
message: Final = str(reason)
return _client_error(str(reason))
def _client_error(message: str) -> BedrockError:
return BedrockError(
status_code=400,
message=message,
@ -336,6 +340,92 @@ def _rejected_file_id(reason: ValueError) -> BedrockError:
)
@dataclass(frozen=True, slots=True)
class _BatchInputLine:
line_number: int
record: _OpenAIBatchRecord
_BATCH_RECORD_CONVERSION_ERRORS: Final = (AttributeError, LookupError, TypeError, ValueError, BadRequestError)
def _batch_line_error(line_number: int, problem: str) -> BedrockError:
return _client_error(f"Batch input file line {line_number} {problem}")
def _decode_batch_input(content: str | bytes) -> str:
if isinstance(content, str):
return content.removeprefix("\ufeff")
try:
return content.decode("utf-8-sig")
except UnicodeDecodeError as e:
raise _batch_line_error(content.count(b"\n", 0, e.start) + 1, f"is not valid UTF-8: {e.reason}") from e
def _load_batch_line(line_number: int, line: str) -> _OpenAIBatchRecord:
try:
return json.loads(line)
except json.JSONDecodeError as e:
raise _batch_line_error(line_number, f"is not valid JSON: {e.msg} at column {e.colno}") from e
except (ValueError, RecursionError) as e:
raise _batch_line_error(line_number, f"could not be parsed: {e}") from e
def _json_object(value: object) -> Mapping[str, object] | None:
return value if isinstance(value, dict) else None
def _batch_json_object(line_number: int, line: str) -> _OpenAIBatchRecord:
record: Final = _load_batch_line(line_number, line)
if _json_object(record) is None:
raise _batch_line_error(line_number, "is not a JSON object")
return record
def _batch_body_problem(record: Mapping[str, object]) -> str | None:
body: Final = _json_object(record.get("body"))
if body is None:
return "has a body that is missing or not a JSON object"
if "model" in body and not isinstance(body["model"], str):
return "has a body.model that is not a string"
return None
def _batch_input_line(line_number: int, line: str) -> _BatchInputLine:
record: Final = _batch_json_object(line_number, line)
problem: Final = _batch_body_problem(record)
if problem is not None:
raise _batch_line_error(line_number, problem)
return _BatchInputLine(line_number=line_number, record=record)
def _non_blank_lines(file_content: str) -> Iterator[tuple[int, str]]:
numbered_lines: Final = enumerate(file_content.splitlines(), start=1)
return ((line_number, line) for line_number, line in numbered_lines if line.strip())
def _parse_batch_jsonl(file_content: str) -> tuple[_BatchInputLine, ...]:
return tuple(_batch_input_line(line_number, line) for line_number, line in _non_blank_lines(file_content))
def _batch_file_model(file_content: str) -> str | None:
records: Final = tuple(
_batch_json_object(line_number, line) for line_number, line in _non_blank_lines(file_content)
)
if not records:
return None
body: Final = _json_object(records[0].get("body"))
model: Final = body.get("model") if body is not None else None
return model if isinstance(model, str) else ""
def _batch_s3_object_name(model: str) -> str:
safe_model: Final = sanitize_cloud_object_component(
model.removeprefix("bedrock/").replace(":", "-"), fallback="model"
)
return f"{BEDROCK_MANAGED_S3_BATCH_PREFIX}{safe_model}-{uuid.uuid4()}.jsonl"
def _resolve_managed_s3_object(file_id: str, litellm_params: Mapping[str, object]) -> tuple[str, str]:
configured_bucket_names: Final = get_configured_s3_bucket_names(litellm_params)
allow_legacy_cloud_file_ids: Final = should_allow_legacy_cloud_file_ids(litellm_params)
@ -545,29 +635,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
# File-like objects need to be read
content = file_content.read()
# Ensure content is string
if isinstance(content, bytes):
content = content.decode("utf-8")
return content
def _get_s3_object_name_from_batch_jsonl(
self,
openai_jsonl_content: Sequence[_OpenAIBatchRecord],
) -> str:
"""
Gets a unique S3 object name for the Bedrock batch processing job
named as: litellm-bedrock-files/{model}/{uuid}
"""
_model = openai_jsonl_content[0].get("body", {}).get("model", "")
# Remove bedrock/ prefix if present
_model = _model.removeprefix("bedrock/")
safe_model: Final = sanitize_cloud_object_component(_model.replace(":", "-"), fallback="model")
object_name: Final = f"{BEDROCK_MANAGED_S3_BATCH_PREFIX}{safe_model}-{uuid.uuid4()}.jsonl"
return object_name
return _decode_batch_input(content)
def get_object_name(self, extracted_file_data: ExtractedFileData, purpose: str) -> str:
"""
@ -581,11 +649,9 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
if purpose == "batch":
## 1. If jsonl, check if there's a model name
file_content: Final = self._get_content_from_openai_file(extracted_file_data_content)
# Split into lines and parse each line as JSON
openai_jsonl_content: Final = [json.loads(line) for line in file_content.splitlines() if line.strip()]
if len(openai_jsonl_content) > 0:
return self._get_s3_object_name_from_batch_jsonl(openai_jsonl_content)
batch_model: Final = _batch_file_model(file_content)
if batch_model is not None:
return _batch_s3_object_name(batch_model)
## 2. If not jsonl, store under a server-generated managed object name
filename: Final = extracted_file_data.get("filename")
@ -1080,42 +1146,60 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
}
}
"""
return [
self._transform_openai_batch_record(idx, openai_record, target_model)
for idx, openai_record in enumerate(openai_jsonl_content)
]
def transform_batch_input_lines(
self, batch_input_lines: Sequence[_BatchInputLine], target_model: str = ""
) -> tuple[_BedrockBatchRecord, ...]:
return tuple(
self._transform_batch_input_line(idx, batch_input_line, target_model)
for idx, batch_input_line in enumerate(batch_input_lines)
)
def _transform_batch_input_line(
self, idx: int, batch_input_line: _BatchInputLine, target_model: str
) -> _BedrockBatchRecord:
try:
return self._transform_openai_batch_record(idx, batch_input_line.record, target_model)
except _BATCH_RECORD_CONVERSION_ERRORS as e:
raise _batch_line_error(
batch_input_line.line_number, f"could not be converted to a Bedrock batch record: {e}"
) from e
def _transform_openai_batch_record(
self, idx: int, openai_record: _OpenAIBatchRecord, target_model: str
) -> _BedrockBatchRecord:
import litellm
bedrock_jsonl_content: Final = []
for idx, _openai_jsonl_content in enumerate(openai_jsonl_content):
# Extract the request body from OpenAI format
openai_body = _openai_jsonl_content.get("body", {})
record_model = openai_body.get("model", "")
resolved_model = litellm.model_alias_map.get(record_model, record_model)
model_for_transform, provider = self._resolve_batch_record_model_and_provider(
record_model=resolved_model, target_model=target_model
openai_body: Final = openai_record.get("body", {})
record_model: Final = openai_body.get("model", "")
resolved_model: Final = litellm.model_alias_map.get(record_model, record_model)
model_for_transform, provider = self._resolve_batch_record_model_and_provider(
record_model=resolved_model, target_model=target_model
)
# Route to the embedding transformer when the OpenAI batch line
# targets /v1/embeddings; every other endpoint shape is normalized
# to chat completions first. We branch here (rather than inside
# `_map_openai_to_bedrock_params`) so the chat helper keeps its
# narrow contract and the embedding helper can evolve independently.
record_kind: Final = self._classify_batch_record(openai_record)
model_input: Final = (
self._map_openai_embedding_to_bedrock_params(openai_request_body=openai_body, model=model_for_transform)
if record_kind is BedrockBatchRecordKind.EMBEDDING
else self._map_openai_to_bedrock_params(
openai_request_body=self._transform_batch_body_to_chat_body(openai_body, record_kind),
model=model_for_transform,
provider=provider,
)
# Route to the embedding transformer when the OpenAI batch line
# targets /v1/embeddings; every other endpoint shape is normalized
# to chat completions first. We branch here (rather than inside
# `_map_openai_to_bedrock_params`) so the chat helper keeps its
# narrow contract and the embedding helper can evolve independently.
record_kind = self._classify_batch_record(_openai_jsonl_content)
if record_kind is BedrockBatchRecordKind.EMBEDDING:
model_input = self._map_openai_embedding_to_bedrock_params(
openai_request_body=openai_body, model=model_for_transform
)
else:
model_input = self._map_openai_to_bedrock_params(
openai_request_body=self._transform_batch_body_to_chat_body(openai_body, record_kind),
model=model_for_transform,
provider=provider,
)
# Create Bedrock batch record
record_id = _openai_jsonl_content.get("custom_id", f"CALL{str(idx).zfill(7)}")
bedrock_record = {"recordId": record_id, "modelInput": model_input}
bedrock_jsonl_content.append(bedrock_record)
return bedrock_jsonl_content
)
return _BedrockBatchRecord(
recordId=openai_record.get("custom_id", f"CALL{str(idx).zfill(7)}"),
modelInput=model_input,
)
def transform_openai_jsonl_content_to_bedrock_jsonl_content(
self,
@ -1151,11 +1235,11 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
):
## Transform JSONL content to Bedrock format
original_file_content: Final = self._get_content_from_openai_file(extracted_file_data_content)
openai_jsonl_content = [json.loads(line) for line in original_file_content.splitlines() if line.strip()]
batch_input_lines: Final = _parse_batch_jsonl(original_file_content)
litellm_params_model: Final = litellm_params.get("model")
target_model: Final = model or (litellm_params_model if isinstance(litellm_params_model, str) else "")
bedrock_jsonl_content = self._transform_openai_jsonl_content_to_bedrock_jsonl_content(
openai_jsonl_content, target_model=target_model
bedrock_jsonl_content: Final = self.transform_batch_input_lines(
batch_input_lines, target_model=target_model
)
file_content = "\n".join(json.dumps(item) for item in bedrock_jsonl_content)
elif isinstance(extracted_file_data_content, bytes):
@ -1662,37 +1746,11 @@ class BedrockJsonlFilesTransformation:
# Read the content of the file
file_content: Final = self._get_content_from_openai_file(openai_file_content)
# Split into lines and parse each line as JSON
openai_jsonl_content: Final = [json.loads(line) for line in file_content.splitlines() if line.strip()]
bedrock_jsonl_content = self._transform_openai_jsonl_content_to_bedrock_jsonl_content(openai_jsonl_content)
batch_input_lines: Final = _parse_batch_jsonl(file_content)
bedrock_jsonl_content: Final = BedrockFilesConfig().transform_batch_input_lines(batch_input_lines)
bedrock_jsonl_string: Final = "\n".join(json.dumps(item) for item in bedrock_jsonl_content)
object_name: Final = self._get_s3_object_name(openai_jsonl_content=openai_jsonl_content)
return bedrock_jsonl_string, object_name
def _transform_openai_jsonl_content_to_bedrock_jsonl_content(
self, openai_jsonl_content: Sequence[_OpenAIBatchRecord]
):
"""
Delegate to the main BedrockFilesConfig transformation method
"""
config: Final = BedrockFilesConfig()
return config.transform_openai_jsonl_content_to_bedrock_jsonl_content(openai_jsonl_content)
def _get_s3_object_name(
self,
openai_jsonl_content: Sequence[_OpenAIBatchRecord],
) -> str:
"""
Gets a unique S3 object name for the Bedrock batch processing job
named as: litellm-bedrock-files-{model}-{uuid}
"""
_model = openai_jsonl_content[0].get("body", {}).get("model", "")
# Remove bedrock/ prefix if present
_model = _model.removeprefix("bedrock/")
safe_model: Final = sanitize_cloud_object_component(_model.replace(":", "-"), fallback="model")
object_name: Final = f"{BEDROCK_MANAGED_S3_BATCH_PREFIX}{safe_model}-{uuid.uuid4()}.jsonl"
return object_name
first_model: Final = batch_input_lines[0].record.get("body", {}).get("model", "") if batch_input_lines else ""
return bedrock_jsonl_string, _batch_s3_object_name(first_model)
def _get_content_from_openai_file(self, openai_file_content: FileTypes) -> str:
"""
@ -1725,11 +1783,7 @@ class BedrockJsonlFilesTransformation:
# File-like objects need to be read
content = file_content.read()
# Ensure content is string
if isinstance(content, bytes):
content = content.decode("utf-8")
return content
return _decode_batch_input(content)
def transform_s3_bucket_response_to_openai_file_object(
self, create_file_data: CreateFileRequest, s3_upload_response: _S3UploadResponse

View file

@ -22,7 +22,8 @@ from botocore.credentials import Credentials
import litellm
from litellm.constants import DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET
from litellm.llms.bedrock.files.transformation import BedrockJsonlFilesTransformation
from litellm.llms.bedrock.common_utils import BedrockError
from litellm.llms.bedrock.files.transformation import BedrockFilesConfig, BedrockJsonlFilesTransformation
from tests.unit.proxy.conftest import httpx_transport
@ -36,9 +37,6 @@ class TestBedrockFilesTransformation:
Validates that the transformation correctly converts OpenAI batch completion
format to Bedrock's expected batch format with proper recordId and modelInput structure.
"""
# Initialize the transformation class
transformation = BedrockJsonlFilesTransformation()
# Load input JSONL file
input_file_path = os.path.join(
os.path.dirname(__file__), "input_batch_completions.jsonl"
@ -52,8 +50,8 @@ class TestBedrockFilesTransformation:
openai_jsonl_content.append(json.loads(line))
# Transform the content
bedrock_jsonl_content = transformation._transform_openai_jsonl_content_to_bedrock_jsonl_content(
openai_jsonl_content=openai_jsonl_content
bedrock_jsonl_content: Final = BedrockFilesConfig().transform_openai_jsonl_content_to_bedrock_jsonl_content(
openai_jsonl_content
)
# Basic validation
@ -408,9 +406,9 @@ class TestBedrockFilesTransformation:
def test_batch_object_name_sanitizes_model_path(self):
from litellm.llms.bedrock.files.transformation import BedrockFilesConfig
config = BedrockFilesConfig()
object_name = config._get_s3_object_name_from_batch_jsonl(
[{"body": {"model": "bedrock/../../secret:model"}}]
object_name: Final = BedrockFilesConfig().get_object_name(
{"content": json.dumps({"body": {"model": "bedrock/../../secret:model"}}).encode(), "filename": "b.jsonl"},
purpose="batch",
)
assert object_name.startswith("litellm-bedrock-files-")
@ -4376,3 +4374,205 @@ async def test_async_create_file(
"s3://litellm-proxy-941277531214/litellm-bedrock-files-"
)
assert file_obj.filename.endswith(".jsonl")
_VALID_BATCH_LINE: Final = json.dumps(
{
"custom_id": "req-1",
"method": "POST",
"url": "/v1/chat/completions",
"body": {
"model": "bedrock/amazon.nova-pro-v1:0",
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 10,
},
}
)
_MALFORMED_BATCH_JSONL: Final = f"{_VALID_BATCH_LINE}\n\n{{not json\n".encode()
class TestMalformedBatchJsonl:
"""A malformed line in a batch input file is the client's mistake, so it must come back as a 400 that names the
line, not escape as a JSONDecodeError the proxy reports as a 500 (#45060)."""
def test_create_file_request_rejects_a_malformed_line_with_a_400(self):
from litellm.llms.bedrock.common_utils import BedrockError
from litellm.llms.bedrock.files.transformation import BedrockFilesConfig
with pytest.raises(BedrockError) as exc_info:
BedrockFilesConfig().transform_create_file_request(
model="amazon.nova-pro-v1:0",
create_file_data={
"file": ("batch.jsonl", _MALFORMED_BATCH_JSONL, "application/jsonl"),
"purpose": "batch",
},
optional_params={
"aws_access_key_id": "test-key-id",
"aws_secret_access_key": "test-secret",
"aws_region_name": "us-west-2",
},
litellm_params={"s3_bucket_name": "litellm-batch-bucket"},
)
assert exc_info.value.status_code == 400
assert "line 3" in exc_info.value.message
def test_file_content_conversion_rejects_a_malformed_line_with_a_400(self):
from litellm.llms.bedrock.common_utils import BedrockError
with pytest.raises(BedrockError) as exc_info:
BedrockJsonlFilesTransformation().transform_openai_file_content_to_bedrock_file_content(
_MALFORMED_BATCH_JSONL
)
assert exc_info.value.status_code == 400
assert "line 3" in exc_info.value.message
@pytest.mark.parametrize("content_type", ["application/jsonl", "application/x-jsonl"])
@pytest.mark.parametrize(
"file_content",
[_MALFORMED_BATCH_JSONL, f"{_VALID_BATCH_LINE}\n\n[1, 2]\n".encode()],
ids=["invalid-json", "array-line"],
)
def test_create_file_rejects_a_malformed_line_before_uploading(self, content_type: str, file_content: bytes):
"""application/x-jsonl is not on the batch MIME allowlist, so the upload skips conversion for it; the line
check must still run so the broken file is rejected instead of landing in S3 unchanged."""
from litellm.llms.bedrock.common_utils import BedrockError
with respx.mock as router:
any_request: Final = router.route()
with pytest.raises(BedrockError) as exc_info:
litellm.create_file(
file=("batch.jsonl", file_content, content_type),
purpose="batch",
custom_llm_provider="bedrock",
model="bedrock/amazon.nova-pro-v1:0",
s3_bucket_name="litellm-batch-bucket",
aws_region_name="us-west-2",
aws_access_key_id="test-key-id",
aws_secret_access_key="test-secret",
)
assert exc_info.value.status_code == 400
assert "line 3" in exc_info.value.message
assert not any_request.called
_VALID_BATCH_RECORD: Final = json.loads(_VALID_BATCH_LINE)
def _batch_line(body: object) -> str:
return json.dumps({**_VALID_BATCH_RECORD, "body": body})
def _batch_line_with_body(field: str, value: object) -> str:
return _batch_line({**_VALID_BATCH_RECORD["body"], field: value})
@pytest.mark.usefixtures(httpx_transport.__name__)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("file_content", "expected_message"),
[
pytest.param(
f"{_VALID_BATCH_LINE}\n[1, 2]\n".encode(), "Batch input file line 2 is not a JSON object", id="array-line"
),
pytest.param(
f"[1, 2]\n{_VALID_BATCH_LINE}\n".encode(), "Batch input file line 1 is not a JSON object", id="array-first"
),
pytest.param(
f"{_VALID_BATCH_LINE}\n{_batch_line(None)}\n".encode(),
"Batch input file line 2 has a body that is missing or not a JSON object",
id="null-body",
),
pytest.param(
f"{_batch_line('hello')}\n".encode(),
"Batch input file line 1 has a body that is missing or not a JSON object",
id="string-body",
),
pytest.param(
f"{_batch_line_with_body('model', 123)}\n".encode(),
"Batch input file line 1 has a body.model that is not a string",
id="int-model",
),
pytest.param(
f"{_batch_line_with_body('model', None)}\n".encode(),
"Batch input file line 1 has a body.model that is not a string",
id="null-model",
),
pytest.param(
f"{_VALID_BATCH_LINE}\n{_batch_line_with_body('messages', 'hi')}\n".encode(),
"Batch input file line 2 could not be converted to a Bedrock batch record",
id="string-messages",
),
pytest.param(
f"{_VALID_BATCH_LINE}\n{'[' * 1_000_000}{']' * 1_000_000}\n".encode(),
"Batch input file line 2 could not be parsed",
id="deep-nesting",
),
pytest.param(
f"{_VALID_BATCH_LINE}\n".encode() + b'{"custom_id": "\xff"}\n',
"Batch input file line 2 is not valid UTF-8",
id="non-utf8",
),
],
)
async def test_create_file_rejects_a_malformed_batch_line_with_a_400_before_uploading(
respx_mock: respx.MockRouter, file_content: bytes, expected_message: str
) -> None:
put_route: Final = respx_mock.put(url__regex=r"https://s3\..+").respond(status_code=200)
with pytest.raises(BedrockError) as exc_info:
await litellm.acreate_file(
file=("batch.jsonl", file_content, "application/jsonl"),
purpose="batch",
custom_llm_provider="bedrock",
s3_bucket_name="litellm-batch-bucket",
aws_region_name="us-west-2",
aws_access_key_id="test-access-key",
aws_secret_access_key="test-secret-key",
)
assert exc_info.value.status_code == 400
assert exc_info.value.message.startswith(expected_message)
assert not put_route.called
@pytest.mark.usefixtures(httpx_transport.__name__)
@pytest.mark.asyncio
async def test_create_file_accepts_a_batch_file_with_a_utf8_byte_order_mark(respx_mock: respx.MockRouter) -> None:
put_route: Final = respx_mock.put(url__regex=r"https://s3\..+").respond(status_code=200)
await litellm.acreate_file(
file=("batch.jsonl", f"\ufeff{_VALID_BATCH_LINE}\n".encode(), "application/jsonl"),
purpose="batch",
custom_llm_provider="bedrock",
s3_bucket_name="litellm-batch-bucket",
aws_region_name="us-west-2",
aws_access_key_id="test-access-key",
aws_secret_access_key="test-secret-key",
)
uploaded_record: Final = json.loads(put_route.calls.last.request.content)
assert uploaded_record["recordId"] == _VALID_BATCH_RECORD["custom_id"]
assert "/litellm-bedrock-files-amazon.nova-pro-v1-0-" in str(put_route.calls.last.request.url)
@pytest.mark.usefixtures(httpx_transport.__name__)
@pytest.mark.asyncio
async def test_create_file_uploads_a_bedrock_format_batch_file_unchanged_when_its_mime_type_skips_conversion(
respx_mock: respx.MockRouter,
) -> None:
put_route: Final = respx_mock.put(url__regex=r"https://s3\..+").respond(status_code=200)
bedrock_line: Final = json.dumps(
{"recordId": "req-1", "modelInput": {"messages": [{"role": "user", "content": [{"text": "Hello"}]}]}}
)
await litellm.acreate_file(
file=("batch.jsonl", f"{bedrock_line}\n".encode(), "application/x-jsonl"),
purpose="batch",
custom_llm_provider="bedrock",
s3_bucket_name="litellm-batch-bucket",
aws_region_name="us-west-2",
aws_access_key_id="test-access-key",
aws_secret_access_key="test-secret-key",
)
assert put_route.calls.last.request.content == f"{bedrock_line}\n".encode()