diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index 4d40e0dec85..edd86960542 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 @@ -322,7 +322,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, @@ -334,6 +337,23 @@ def _rejected_file_id(reason: ValueError) -> BedrockError: ) +def _parse_batch_jsonl(file_content: str) -> tuple[Any, ...]: + """Parse a batch input file one line at a time. A malformed line is the client's mistake, so it comes back as a + 400 naming the line instead of a JSONDecodeError the proxy would report as a 500.""" + return tuple(_iter_batch_jsonl(file_content)) + + +def _iter_batch_jsonl(file_content: str) -> Iterator[Any]: + for line_number, line in enumerate(file_content.splitlines(), start=1): + if not line.strip(): + continue + try: + record = json.loads(line) + except json.JSONDecodeError as e: + raise _client_error(f"Invalid JSON on line {line_number} of the batch input file: {e.msg}") from e + yield record + + 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) @@ -580,10 +600,12 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): ## 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) + # Split into lines and parse each line as JSON. A file that is not JSONL gets the generic name below; the + # upload's own conversion is what rejects a malformed batch file. + with suppress(json.JSONDecodeError): + 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) ## 2. If not jsonl, store under a server-generated managed object name filename: Final = extracted_file_data.get("filename") @@ -1147,7 +1169,7 @@ 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()] + openai_jsonl_content = _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( @@ -1659,7 +1681,7 @@ class BedrockJsonlFilesTransformation: 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()] + openai_jsonl_content: Final = _parse_batch_jsonl(file_content) bedrock_jsonl_content = self._transform_openai_jsonl_content_to_bedrock_jsonl_content(openai_jsonl_content) 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) 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 425beefac8a..4fd2e1552ef 100644 --- a/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py +++ b/tests/unit/llms/bedrock/files/test_bedrock_files_transformation.py @@ -4334,3 +4334,62 @@ 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{{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 2" 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 2" in exc_info.value.message + + def test_object_name_inference_still_falls_back_for_a_non_json_file(self): + from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + object_name = BedrockFilesConfig().get_object_name( + {"content": b"just some text, not jsonl", "filename": "notes.txt"}, purpose="batch" + ) + assert object_name.endswith("notes.txt")