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
This commit is contained in:
Abhishek Acharya 2026-10-12 12:58:53 +11:00
parent 102619d47a
commit d205ba9cc5
2 changed files with 89 additions and 8 deletions

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
@ -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)

View file

@ -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")