mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
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:
parent
102619d47a
commit
d205ba9cc5
2 changed files with 89 additions and 8 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue