mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-27 06:13:07 +00:00
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:
parent
81d8c9af38
commit
7655c2c839
3 changed files with 364 additions and 110 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue