diff --git a/litellm/llms/bedrock/batches/transformation.py b/litellm/llms/bedrock/batches/transformation.py index 3ad8049ec26..50fb911709a 100644 --- a/litellm/llms/bedrock/batches/transformation.py +++ b/litellm/llms/bedrock/batches/transformation.py @@ -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( diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index ce2c8f15d66..cb10c2384cd 100644 --- a/litellm/llms/bedrock/files/transformation.py +++ b/litellm/llms/bedrock/files/transformation.py @@ -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 diff --git a/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py index d64a9ee845a..7e05ef500f5 100644 --- a/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py +++ b/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py @@ -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()